Symfony 7: API Platform e Boas Praticas

Guia completo para criar APIs REST profissionais com Symfony 7 e API Platform 4. State Providers, Processors, validacao e serializacao explicados com exemplos praticos.

Guia de API Platform e Boas Praticas com Symfony 7

O API Platform 4 transforma radicalmente a criacao de APIs REST e GraphQL com Symfony 7. Esta nova versao traz uma filosofia repensada: separacao clara de responsabilidades, State Providers e Processors simplificados, e integracao nativa do Symfony Object Mapper. Construir uma API profissional nunca foi tao intuitivo.

Novidades no API Platform 4.2

A versao 4.2 introduz o JSON Streamer com ganhos de desempenho de ate +32% de RPS, um sistema de filtros redesenhado e Mutators para personalizar operacoes sem alterar o nucleo. O suporte ao Symfony 7 e 8 e nativo.

Instalacao e Configuracao Inicial

O API Platform e instalado em poucos comandos com o Symfony Flex. A configuracao padrao cobre a maioria dos casos de uso e permanece totalmente personalizavel.

bash
# terminal
# Create a new Symfony project with API Platform
composer create-project symfony/skeleton my-api
cd my-api

# Install API Platform with Doctrine ORM
composer require api

# Verify installation
php bin/console debug:router | grep api

O Symfony Flex configura automaticamente as rotas, a documentacao OpenAPI e a interface Swagger UI acessivel em /api.

yaml
# config/packages/api_platform.yaml
api_platform:
    title: 'My API'
    version: '1.0.0'
    # Supported response formats
    formats:
        jsonld: ['application/ld+json']
        json: ['application/json']
    # OpenAPI documentation
    swagger:
        versions: [3]
    # Default pagination
    defaults:
        pagination_items_per_page: 30
        pagination_maximum_items_per_page: 100

Essa configuracao define os formatos de serializacao, a paginacao global e os metadados da documentacao da API.

Criacao de um Recurso API Simples

O atributo #[ApiResource] expoe uma entidade Doctrine como recurso REST. O API Platform gera automaticamente os endpoints CRUD, a documentacao OpenAPI e as validacoes basicas.

php
<?php
// src/Entity/Book.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\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    // Explicit definition of available operations
    operations: [
        new GetCollection(),
        new Post(),
        new Get(),
        new Put(),
        new Patch(),
        new Delete(),
    ],
    // Default ordering for collections
    order: ['publishedAt' => 'DESC'],
    // Pagination configuration for this resource
    paginationItemsPerPage: 20
)]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank(message: 'Title is required')]
    #[Assert\Length(min: 2, max: 255)]
    private ?string $title = null;

    #[ORM\Column(type: 'text')]
    #[Assert\NotBlank]
    private ?string $description = null;

    #[ORM\Column(length: 13, unique: true)]
    #[Assert\Isbn]
    private ?string $isbn = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $publishedAt = null;

    // Getters and setters...
    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;
        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(string $description): static
    {
        $this->description = $description;
        return $this;
    }

    public function getIsbn(): ?string
    {
        return $this->isbn;
    }

    public function setIsbn(string $isbn): static
    {
        $this->isbn = $isbn;
        return $this;
    }

    public function getPublishedAt(): ?\DateTimeImmutable
    {
        return $this->publishedAt;
    }

    public function setPublishedAt(\DateTimeImmutable $publishedAt): static
    {
        $this->publishedAt = $publishedAt;
        return $this;
    }
}

Essa entidade gera seis endpoints: GET /api/books, POST /api/books, GET /api/books/{id}, PUT /api/books/{id}, PATCH /api/books/{id} e DELETE /api/books/{id}.

UUID v7 Recomendado

O API Platform suporta nativamente o UUID v7 como identificador. Essa abordagem melhora a seguranca (identificadores nao previssiveis) e o desempenho (ordenacao natural por data de criacao).

Grupos de Serializacao para Controlar Dados Expostos

Os grupos de serializacao definem com precisao quais propriedades sao expostas para leitura (normalizacao) e escrita (desnormalizacao). Essa separacao e essencial para a seguranca e flexibilidade da API.

php
<?php
// src/Entity/User.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\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use App\State\UserPasswordHasher;

#[ORM\Entity]
#[ORM\Table(name: '`user`')]
#[UniqueEntity('email')]
#[ApiResource(
    operations: [
        new GetCollection(),
        // Custom processor for password hashing
        new Post(processor: UserPasswordHasher::class,
                 validationContext: ['groups' => ['Default', 'user:create']]),
        new Get(),
        new Put(processor: UserPasswordHasher::class),
        new Patch(processor: UserPasswordHasher::class),
        new Delete(),
    ],
    // Properties exposed when reading
    normalizationContext: ['groups' => ['user:read']],
    // Properties accepted when writing
    denormalizationContext: ['groups' => ['user:create', 'user:update']],
)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['user:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    #[Assert\NotBlank]
    #[Assert\Email]
    #[Groups(['user:read', 'user:create', 'user:update'])]
    private ?string $email = null;

    #[ORM\Column]
    private ?string $password = null;

    // Never exposed when reading, only when writing
    #[Assert\NotBlank(groups: ['user:create'])]
    #[Groups(['user:create', 'user:update'])]
    private ?string $plainPassword = null;

    #[ORM\Column(length: 100)]
    #[Groups(['user:read', 'user:create', 'user:update'])]
    private ?string $fullName = null;

    #[ORM\Column(type: 'json')]
    #[Groups(['user:read'])]
    private array $roles = [];

    #[ORM\Column]
    #[Groups(['user:read'])]
    private ?\DateTimeImmutable $createdAt = null;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    // UserInterface implementation
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    public function getRoles(): array
    {
        $roles = $this->roles;
        $roles[] = 'ROLE_USER';
        return array_unique($roles);
    }

    public function getPassword(): string
    {
        return $this->password;
    }

    public function eraseCredentials(): void
    {
        $this->plainPassword = null;
    }

    // Getters and setters...
    public function getId(): ?int
    {
        return $this->id;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(string $email): static
    {
        $this->email = $email;
        return $this;
    }

    public function setPassword(string $password): static
    {
        $this->password = $password;
        return $this;
    }

    public function getPlainPassword(): ?string
    {
        return $this->plainPassword;
    }

    public function setPlainPassword(?string $plainPassword): static
    {
        $this->plainPassword = $plainPassword;
        return $this;
    }

    public function getFullName(): ?string
    {
        return $this->fullName;
    }

    public function setFullName(string $fullName): static
    {
        $this->fullName = $fullName;
        return $this;
    }

    public function setRoles(array $roles): static
    {
        $this->roles = $roles;
        return $this;
    }

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }
}

Com essa configuracao, plainPassword nunca e exposto nas respostas, mas pode ser enviado durante a criacao ou atualizacoes.

Pronto para mandar bem nas entrevistas de Symfony?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

State Processors para Logica de Negocio

Os State Processors interceptam as operacoes de persistencia para adicionar logica de negocio. O API Platform 4 simplifica sua criacao por meio de injecao de dependencia baseada em atributos.

php
<?php
// src/State/UserPasswordHasher.php

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

/**
 * Processor that hashes password before persistence
 * @implements ProcessorInterface<User, User>
 */
final class UserPasswordHasher implements ProcessorInterface
{
    public function __construct(
        // Injection of standard Doctrine processor
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
        private ProcessorInterface $persistProcessor,
        private UserPasswordHasherInterface $passwordHasher,
    ) {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User
    {
        // Hash password if provided
        if ($data->getPlainPassword()) {
            $hashedPassword = $this->passwordHasher->hashPassword(
                $data,
                $data->getPlainPassword()
            );
            $data->setPassword($hashedPassword);
            $data->eraseCredentials();
        }

        // Delegate persistence to standard processor
        return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
    }
}

Esse padrao de composicao permite adicionar qualquer logica (envio de e-mails, eventos, logs) preservando o comportamento padrao de persistencia.

Processor com Logica Condicional

Um processor pode adaptar seu comportamento com base no tipo de operacao.

php
<?php
// src/State/BookProcessor.php

namespace App\State;

use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Book;
use App\Service\NotificationService;
use App\Service\SearchIndexer;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * @implements ProcessorInterface<Book, Book|void>
 */
final class BookProcessor implements ProcessorInterface
{
    public function __construct(
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
        private ProcessorInterface $persistProcessor,
        #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
        private ProcessorInterface $removeProcessor,
        private NotificationService $notifications,
        private SearchIndexer $searchIndexer,
    ) {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        // Deletion: use remove processor
        if ($operation instanceof DeleteOperationInterface) {
            $this->searchIndexer->remove($data);
            return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
        }

        // Creation: set publication date
        if ($operation instanceof Post) {
            $data->setPublishedAt(new \DateTimeImmutable());
        }

        // Standard persistence
        $result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);

        // Post-processing: indexing and notification
        $this->searchIndexer->index($result);

        if ($operation instanceof Post) {
            $this->notifications->notifyNewBook($result);
        }

        return $result;
    }
}

State Providers para Fontes de Dados Personalizadas

Os State Providers buscam dados de qualquer fonte: APIs externas, cache, arquivos ou logica de negocio complexa.

php
<?php
// src/State/PopularBooksProvider.php

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Book;
use App\Repository\BookRepository;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

/**
 * Provider that returns popular books with caching
 * @implements ProviderInterface<Book>
 */
final class PopularBooksProvider implements ProviderInterface
{
    public function __construct(
        private BookRepository $bookRepository,
        private CacheInterface $cache,
    ) {
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
    {
        // 5-minute cache for popular books
        return $this->cache->get('popular_books', function (ItemInterface $item) {
            $item->expiresAfter(300);

            return $this->bookRepository->findPopular(limit: 10);
        });
    }
}

Esse provider e utilizado em uma operacao dedicada.

php
<?php
// src/Entity/Book.php (excerpt)

use App\State\PopularBooksProvider;

#[ApiResource(
    operations: [
        // ... other operations
        new GetCollection(
            uriTemplate: '/books/popular',
            provider: PopularBooksProvider::class,
            paginationEnabled: false,
        ),
    ],
)]
class Book
{
    // ...
}

Validacao Avancada com Grupos Dinamicos

O API Platform integra nativamente o componente Validator do Symfony. Os grupos de validacao podem variar conforme a operacao ou o contexto.

php
<?php
// src/Entity/Article.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 Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        // Strict validation on creation
        new Post(validationContext: ['groups' => ['Default', 'article:create']]),
        // More lenient validation for updates
        new Put(validationContext: ['groups' => ['Default', 'article:update']]),
    ],
)]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(min: 10, max: 255)]
    private ?string $title = null;

    #[ORM\Column(type: 'text')]
    #[Assert\NotBlank]
    // Minimum 500 characters on creation
    #[Assert\Length(min: 500, groups: ['article:create'])]
    // Minimum 100 characters for updates
    #[Assert\Length(min: 100, groups: ['article:update'])]
    private ?string $content = null;

    #[ORM\Column(length: 50)]
    #[Assert\NotBlank(groups: ['article:create'])]
    #[Assert\Choice(choices: ['draft', 'published', 'archived'])]
    private ?string $status = 'draft';

    #[ORM\Column(nullable: true)]
    // Required only if status is "published"
    #[Assert\NotBlank(groups: ['article:publish'])]
    private ?\DateTimeImmutable $publishedAt = null;

    // Getters and setters...
}

Validacao Dinamica com um Servico

Para regras de validacao complexas, um gerador de grupos personalizado oferece flexibilidade total.

php
<?php
// src/Validator/ArticleGroupsGenerator.php

namespace App\Validator;

use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;

final class ArticleGroupsGenerator implements ValidationGroupsGeneratorInterface
{
    public function __construct(
        private Security $security,
    ) {
    }

    public function __invoke(object $object): array
    {
        assert($object instanceof Article);

        $groups = ['Default'];

        // Admins have fewer restrictions
        if ($this->security->isGranted('ROLE_ADMIN')) {
            $groups[] = 'admin';
            return $groups;
        }

        // Additional validation if publishing
        if ($object->getStatus() === 'published') {
            $groups[] = 'article:publish';
        }

        return $groups;
    }
}
Desempenho de Validacao

Validacoes complexas podem impactar o desempenho. Para importacoes em massa, considere desabilitar temporariamente certas validacoes ou utilizar constraints assincronas.

Filtros para Consultas Flexiveis

O API Platform 4.2 repensa completamente o sistema de filtros com separacao clara de responsabilidades. Os filtros permitem que os clientes da API pesquisem e ordenem dados.

php
<?php
// src/Entity/Product.php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ApiResource]
// Text search on name and description
#[ApiFilter(SearchFilter::class, properties: [
    'name' => 'partial',       // LIKE %value%
    'description' => 'partial',
    'category.name' => 'exact', // Search on relation
    'sku' => 'exact',          // Exact match
])]
// Range filtering
#[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])]
// Date filtering
#[ApiFilter(DateFilter::class, properties: ['createdAt', 'updatedAt'])]
// Boolean filtering
#[ApiFilter(BooleanFilter::class, properties: ['isActive', 'isFeatured'])]
// Customizable sorting
#[ApiFilter(OrderFilter::class, properties: [
    'name',
    'price',
    'createdAt',
], arguments: ['orderParameterName' => 'sort'])]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $description = null;

    #[ORM\Column(length: 50, unique: true)]
    private ?string $sku = null;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private ?string $price = null;

    #[ORM\Column]
    private ?int $stock = null;

    #[ORM\Column]
    private ?bool $isActive = true;

    #[ORM\Column]
    private ?bool $isFeatured = false;

    #[ORM\ManyToOne(targetEntity: Category::class)]
    private ?Category $category = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $updatedAt = null;

    // Getters and setters...
}

Esses filtros geram automaticamente documentacao OpenAPI e habilitam consultas como:

text
GET /api/products?name=phone&price[gte]=100&price[lte]=500&isActive=true&sort[price]=asc

Relacoes e Subrecursos

O API Platform trata de forma elegante as relacoes entre entidades, com opcoes de serializacao e subrecursos.

php
<?php
// src/Entity/Author.php

namespace App\Entity;

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

#[ORM\Entity]
#[ApiResource(
    normalizationContext: ['groups' => ['author:read']],
)]
class Author
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['author:read', 'book:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['author:read', 'book:read'])]
    private ?string $name = null;

    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups(['author:read'])]
    private ?string $biography = null;

    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Book::class)]
    #[Groups(['author:read'])]
    private Collection $books;

    public function __construct()
    {
        $this->books = new ArrayCollection();
    }

    // Getters and setters...
}
php
<?php
// src/Entity/Book.php (with relation)

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity]
#[ApiResource(
    normalizationContext: ['groups' => ['book:read']],
)]
// Subresource: GET /api/authors/{authorId}/books
#[ApiResource(
    uriTemplate: '/authors/{authorId}/books',
    operations: [new GetCollection()],
    uriVariables: [
        'authorId' => new Link(
            fromProperty: 'books',
            fromClass: Author::class
        ),
    ],
    normalizationContext: ['groups' => ['book:read']],
)]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['book:read', 'author:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['book:read', 'author:read'])]
    private ?string $title = null;

    #[ORM\ManyToOne(targetEntity: Author::class, inversedBy: 'books')]
    #[ORM\JoinColumn(nullable: false)]
    #[Groups(['book:read'])]
    private ?Author $author = null;

    // Getters and setters...
}

Seguranca e Controle de Acesso

O API Platform se integra perfeitamente ao sistema de seguranca do Symfony. Voters e expressoes de seguranca controlam o acesso aos recursos.

php
<?php
// src/Entity/Order.php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ApiResource(
    operations: [
        // List: authenticated users see their orders
        new GetCollection(
            security: "is_granted('ROLE_USER')",
            // Automatic filter by connected user
            provider: UserOrdersProvider::class,
        ),
        // Creation: any authenticated user
        new Post(
            security: "is_granted('ROLE_USER')",
            processor: CreateOrderProcessor::class,
        ),
        // Read: owner or admin
        new Get(
            security: "is_granted('ROLE_ADMIN') or object.getCustomer() == user",
            securityMessage: "Access denied to this order.",
        ),
        // Modification: admin only
        new Patch(
            security: "is_granted('ROLE_ADMIN')",
        ),
        // Deletion: admin only
        new Delete(
            security: "is_granted('ROLE_ADMIN')",
        ),
    ],
)]
class Order
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $customer = null;

    #[ORM\Column(length: 50)]
    private ?string $status = 'pending';

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private ?string $total = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    // Getters...
    public function getCustomer(): ?User
    {
        return $this->customer;
    }
}

A expressao object.getCustomer() == user permite acessar a entidade atual e o usuario conectado para verificacoes granulares.

Testes Automatizados de API

O API Platform fornece traits do PHPUnit para testar endpoints de forma simples e eficaz.

php
<?php
// tests/Api/BookTest.php

namespace App\Tests\Api;

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

class BookTest extends ApiTestCase
{
    use ResetDatabase;
    use Factories;

    public function testGetCollection(): void
    {
        // Create test data
        BookFactory::createMany(30);

        // GET request on collection
        $response = static::createClient()->request('GET', '/api/books');

        // Assertions
        $this->assertResponseIsSuccessful();
        $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
        $this->assertJsonContains([
            '@context' => '/api/contexts/Book',
            '@type' => 'Collection',
            'totalItems' => 30,
        ]);
        // Verify pagination (20 items per page)
        $this->assertCount(20, $response->toArray()['member']);
    }

    public function testCreateBook(): void
    {
        $user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);

        static::createClient()->request('POST', '/api/books', [
            'auth_bearer' => $this->getToken($user),
            'json' => [
                'title' => 'Clean Code',
                'description' => 'A Handbook of Agile Software Craftsmanship',
                'isbn' => '9780132350884',
            ],
        ]);

        $this->assertResponseStatusCodeSame(201);
        $this->assertJsonContains([
            '@type' => 'Book',
            'title' => 'Clean Code',
        ]);
    }

    public function testCreateBookValidationFails(): void
    {
        $user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);

        static::createClient()->request('POST', '/api/books', [
            'auth_bearer' => $this->getToken($user),
            'json' => [
                'title' => '', // Empty title = error
                'isbn' => 'invalid-isbn',
            ],
        ]);

        $this->assertResponseStatusCodeSame(422);
        $this->assertJsonContains([
            '@type' => 'ConstraintViolationList',
            'violations' => [
                ['propertyPath' => 'title', 'message' => 'Title is required'],
            ],
        ]);
    }

    private function getToken(object $user): string
    {
        // Implementation depends on your authentication system
        return 'test_token';
    }
}

Conclusao

O API Platform 4 com Symfony 7 representa o estado da arte na criacao de APIs REST profissionais em PHP. A separacao clara entre State Providers (leitura) e State Processors (escrita), combinada com grupos de serializacao e o sistema de validacao, permite construir APIs robustas e de facil manutencao.

Checklist para APIs de Qualidade

  • Utilize grupos de serializacao distintos para leitura e escrita
  • Implemente State Processors para logica de negocio (hash de senha, notificacoes)
  • Configure filtros para pesquisa e ordenacao
  • Aplique validacoes por operacao com grupos
  • Proteja os endpoints com expressoes de seguranca
  • Escreva testes funcionais para cada endpoint
  • Documente a API por meio de metadados OpenAPI

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

A filosofia do API Platform 4 privilegia a composicao em detrimento da heranca e a configuracao em vez da convencao. O resultado: APIs escalaveis, testaveis e conformes aos padroes REST/JSON-LD, prontas para producao desde o primeiro dia.

Tags

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

Compartilhar

Artigos relacionados