Symfony 7: API Platform e Best Practices

Guida completa ad API Platform 4 con Symfony 7: State Processors, State Providers, gruppi di serializzazione, filtri, sicurezza e test automatizzati per API REST professionali.

Symfony 7 e API Platform 4: best practices per la creazione di API REST in PHP

API Platform 4 trasforma radicalmente la creazione di API REST e GraphQL con Symfony 7. Questa versione introduce una filosofia rinnovata: separazione netta delle responsabilità, State Providers e Processors semplificati, e integrazione nativa del Symfony Object Mapper. La costruzione di un'API professionale non è mai stata così coerente e manutenibile.

Novità di API Platform 4.2

La versione 4.2 introduce il JSON Streamer con guadagni di prestazioni fino al +32% in RPS, un sistema di filtri ridisegnato e i Mutators per personalizzare le operazioni senza modificare il core. Il supporto a Symfony 7 e 8 è nativo.

Installazione e Configurazione Iniziale

API Platform si installa in pochi comandi tramite Symfony Flex. La configurazione predefinita copre la maggior parte dei casi d'uso pur rimanendo completamente personalizzabile.

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

Symfony Flex configura automaticamente le rotte, la documentazione OpenAPI e l'interfaccia Swagger UI accessibile su /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

Questa configurazione definisce i formati di serializzazione, la paginazione globale e i metadati della documentazione API.

Creazione di una Risorsa API Semplice

L'attributo #[ApiResource] espone un'entità Doctrine come risorsa REST. API Platform genera automaticamente gli endpoint CRUD, la documentazione OpenAPI e le validazioni di base.

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

Questa entità genera sei endpoint: 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 Consigliato

API Platform supporta nativamente UUID v7 come identificatori. Questo approccio migliora la sicurezza (identificatori non predicibili) e le prestazioni (ordinamento naturale per data di creazione).

Gruppi di Serializzazione per Controllare i Dati Esposti

I gruppi di serializzazione definiscono con precisione quali proprietà vengono esposte in lettura (normalizzazione) e in scrittura (denormalizzazione). Questa separazione è fondamentale per la sicurezza e la flessibilità dell'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;
    }
}

Con questa configurazione, plainPassword non viene mai esposto nelle risposte ma può essere inviato durante la creazione o l'aggiornamento.

Pronto a superare i tuoi colloqui su Symfony?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

State Processors per la Logica di Business

Gli State Processors intercettano le operazioni di persistenza per aggiungere logica di business. API Platform 4 semplifica la loro creazione tramite dependency injection basata sugli attributi.

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

Questo pattern a composizione consente di aggiungere qualsiasi logica (invio di email, eventi, logging) preservando il comportamento standard di persistenza.

Processor con Logica Condizionale

Un processor può adattare il proprio comportamento in base al tipo di operazione.

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 per Fonti di Dati Personalizzate

Gli State Providers recuperano dati da qualsiasi sorgente: API esterne, cache, file o logica di business complessa.

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

Questo provider viene utilizzato su un'operazione dedicata.

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
{
    // ...
}

Validazione Avanzata con Gruppi Dinamici

API Platform integra nativamente il componente Validator di Symfony. I gruppi di validazione possono variare in base all'operazione o al contesto.

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...
}

Validazione Dinamica tramite Servizio

Per regole di validazione complesse, un generatore di gruppi personalizzato offre totale flessibilità.

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;
    }
}
Prestazioni di Validazione

Le validazioni complesse possono influire sulle prestazioni. Per importazioni massicce, si valuta la possibilità di disabilitare temporaneamente alcune validazioni o di utilizzare vincoli asincroni.

Filtri per Query Flessibili

API Platform 4.2 ridisegna completamente il sistema di filtri con una chiara separazione delle responsabilità. I filtri permettono ai client API di cercare e ordinare i dati.

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...
}

Questi filtri generano automaticamente la documentazione OpenAPI e abilitano query come:

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

Relazioni e Sottoriorse

API Platform gestisce con eleganza le relazioni tra entità, offrendo opzioni di serializzazione e sottorisorse.

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...
}

Sicurezza e Controllo degli Accessi

API Platform si integra perfettamente con il sistema di sicurezza di Symfony. Voter ed espressioni di sicurezza controllano l'accesso alle risorse.

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

L'espressione object.getCustomer() == user dà accesso all'entità corrente e all'utente connesso per verifiche granulari.

Test Automatizzati delle API

API Platform fornisce trait PHPUnit per testare facilmente gli endpoint.

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

Conclusione

API Platform 4 con Symfony 7 rappresenta lo stato dell'arte per la creazione di API REST professionali in PHP. La separazione netta tra State Providers (lettura) e State Processors (scrittura), combinata con i gruppi di serializzazione e il sistema di validazione, permette di costruire API robuste e manutenibili.

Checklist per API di Qualità

  • Utilizzare gruppi di serializzazione distinti per lettura e scrittura
  • Implementare State Processors per la logica di business (hashing password, notifiche)
  • Configurare filtri per ricerca e ordinamento
  • Applicare validazioni per operazione tramite gruppi
  • Proteggere gli endpoint con espressioni di sicurezza
  • Scrivere test funzionali per ogni endpoint
  • Documentare l'API tramite i metadati OpenAPI

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

La filosofia di API Platform 4 privilegia la composizione rispetto all'ereditarietà e la configurazione rispetto alla convenzione. Il risultato sono API scalabili, testabili e conformi agli standard REST/JSON-LD, pronte per la produzione fin dal primo giorno.

Tag

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

Condividi

Articoli correlati