Symfony 7: API Platform and Best Practices

Complete guide to building professional REST APIs with Symfony 7 and API Platform 4. State Providers, Processors, validation, and serialization explained.

API Platform and Best Practices Guide for Symfony 7

API Platform 4 radically transforms REST and GraphQL API creation with Symfony 7. This new version brings a rethought philosophy: clear separation of concerns, simplified State Providers and Processors, and native integration of the Symfony Object Mapper. Building a professional API has never been more intuitive.

What's New in API Platform 4.2

Version 4.2 introduces the JSON Streamer for performance gains up to +32% RPS, a redesigned filter system, and Mutators to customize operations without touching the core. Symfony 7 and 8 support is native.

Installation and Initial Configuration

API Platform installs in a few commands with Symfony Flex. The default configuration covers most use cases while remaining fully customizable.

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 automatically configures routes, OpenAPI documentation, and the Swagger UI interface accessible at /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

This configuration defines serialization formats, global pagination, and API documentation metadata.

Creating a Simple API Resource

The #[ApiResource] attribute exposes a Doctrine entity as a REST resource. API Platform automatically generates CRUD endpoints, OpenAPI documentation, and basic validations.

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

This entity generates six endpoints: GET /api/books, POST /api/books, GET /api/books/{id}, PUT /api/books/{id}, PATCH /api/books/{id}, and DELETE /api/books/{id}.

UUID v7 Recommended

API Platform natively supports UUID v7 as identifiers. This approach improves security (non-predictable identifiers) and performance (natural sorting by creation date).

Serialization Groups for Controlling Exposed Data

Serialization groups precisely define which properties are exposed for reading (normalization) and writing (denormalization). This separation is essential for API security and flexibility.

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

With this configuration, plainPassword is never exposed in responses but can be sent during creation or updates.

Ready to ace your Symfony interviews?

Practice with our interactive simulators, flashcards, and technical tests.

State Processors for Business Logic

State Processors intercept persistence operations to add business logic. API Platform 4 simplifies their creation through attribute-based dependency injection.

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

This composition pattern allows adding any logic (sending emails, events, logging) while preserving standard persistence behavior.

Processor with Conditional Logic

A processor can adapt its behavior based on operation type.

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 for Custom Data Sources

State Providers fetch data from any source: external APIs, cache, files, or complex business logic.

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

This provider is used on a dedicated operation.

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

Advanced Validation with Dynamic Groups

API Platform natively integrates Symfony's Validator component. Validation groups can vary based on operation or context.

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

Dynamic Validation with a Service

For complex validation rules, a custom group generator offers total flexibility.

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

Complex validations can impact performance. For bulk imports, consider temporarily disabling certain validations or using asynchronous constraints.

Filters for Flexible Queries

API Platform 4.2 completely rethinks the filter system with clear separation of concerns. Filters allow API clients to search and sort data.

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

These filters automatically generate OpenAPI documentation and enable queries like:

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

Relations and Subresources

API Platform elegantly handles entity relations with serialization options and subresources.

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

Security and Access Control

API Platform integrates seamlessly with Symfony's security system. Voters and security expressions control resource access.

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

The expression object.getCustomer() == user provides access to the current entity and connected user for fine-grained checks.

Automated API Testing

API Platform provides PHPUnit traits for easily testing endpoints.

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

Conclusion

API Platform 4 with Symfony 7 represents the state of the art for creating professional REST APIs in PHP. The clear separation between State Providers (reading) and State Processors (writing), combined with serialization groups and the validation system, enables building robust and maintainable APIs.

Checklist for Quality APIs

  • ✅ Use distinct serialization groups for reading and writing
  • ✅ Implement State Processors for business logic (password hashing, notifications)
  • ✅ Configure filters for searching and sorting
  • ✅ Apply per-operation validations with groups
  • ✅ Secure endpoints with security expressions
  • ✅ Write functional tests for each endpoint
  • ✅ Document the API via OpenAPI metadata

Start practicing!

Test your knowledge with our interview simulators and technical tests.

API Platform 4's philosophy encourages composition over inheritance, and configuration over convention. The result: scalable, testable APIs that conform to REST/JSON-LD standards, production-ready from day one.

Tags

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

Share

Related articles