Symfony 7: API Platform und Best Practices
Vollstaendiger Leitfaden zu API Platform 4 mit Symfony 7. State Processors, State Providers, Serialisierungsgruppen und erweiterte Validierung fuer professionelle REST-APIs.

API Platform 4, kombiniert mit Symfony 7, hat sich als der leistungsfaehigste Ansatz fuer die Entwicklung moderner REST- und GraphQL-APIs in der PHP-Oekosystem etabliert. Dieses Framework bietet automatische OpenAPI-Dokumentation, integrierte Paginierung, Filter, Validierung und Serialisierung — alles aus einer einzigen, deklarativen Konfiguration heraus. Mit den Neuerungen von API Platform 4.2 und Symfony 7.2 stehen Entwicklern noch maechtiger Werkzeuge zur Verfuegung, um produktionsreife APIs zu bauen.
API Platform 4.2 bringt State Processors und State Providers als vollstaendigen Ersatz fuer DataTransformers und DataProviders der alten Versionen. Diese neuen Konzepte trennen Lese- und Schreiboperationen klar voneinander und machen den Code testbarer und wartbarer. Ausserdem unterstuetzt API Platform 4.2 nativ UUID v7 fuer Ressourcen-IDs.
Installation und Erstkonfiguration
Ein neues Symfony-Projekt mit API Platform wird ueber Composer erstellt. Der API Platform Installer richtet alle notwendigen Abhaengigkeiten ein.
# Installation via Composer
composer create-project symfony/skeleton my-api
cd my-api
composer require api
# Alternatively with the API Platform distribution
composer create-project api-platform/api-platform my-api
# Install development tools
composer require --dev symfony/maker-bundle doctrine/doctrine-fixtures-bundle
# Configure the database (PostgreSQL recommended)
composer require doctrine/doctrine-bundle doctrine/orm
# Start the development server
symfony serve -dDie Grundkonfiguration von API Platform erfolgt in config/packages/api_platform.yaml.
# config/packages/api_platform.yaml
api_platform:
title: 'My API'
version: '1.0.0'
description: 'API built with API Platform 4 and Symfony 7'
# Default formats
formats:
jsonld:
mime_types: ['application/ld+json']
json:
mime_types: ['application/json']
html:
mime_types: ['text/html']
# Default pagination
defaults:
pagination_enabled: true
pagination_items_per_page: 20
pagination_maximum_items_per_page: 100
# OpenAPI documentation
openapi:
contact:
name: 'API Support'
url: 'https://example.com/support'
email: 'support@example.com'Erstellen einer einfachen API-Ressource
Mit dem #[ApiResource]-Attribut wird jede Doctrine-Entitaet zu einer vollstaendigen REST-API-Ressource, inklusive automatischer CRUD-Operationen, OpenAPI-Dokumentation und Validierung.
<?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 App\Repository\BookRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: BookRepository::class)]
#[ApiResource(
operations: [
new GetCollection(),
new Post(),
new Get(),
new Put(),
new Patch(),
new Delete(),
],
normalizationContext: ['groups' => ['book:read']],
denormalizationContext: ['groups' => ['book:write']],
paginationItemsPerPage: 10,
)]
class Book
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 255)]
private ?string $title = null;
#[ORM\Column(length: 13, unique: true)]
#[Assert\NotBlank]
#[Assert\Isbn]
private ?string $isbn = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column]
#[Assert\NotNull]
#[Assert\Positive]
private ?float $price = null;
#[ORM\Column]
private \DateTimeImmutable $publishedAt;
public function getId(): ?Uuid { return $this->id; }
public function getTitle(): ?string { return $this->title; }
public function setTitle(string $title): static { $this->title = $title; return $this; }
public function getIsbn(): ?string { return $this->isbn; }
public function setIsbn(string $isbn): static { $this->isbn = $isbn; return $this; }
public function getDescription(): ?string { return $this->description; }
public function setDescription(?string $description): static { $this->description = $description; return $this; }
public function getPrice(): ?float { return $this->price; }
public function setPrice(float $price): static { $this->price = $price; return $this; }
public function getPublishedAt(): \DateTimeImmutable { return $this->publishedAt; }
public function setPublishedAt(\DateTimeImmutable $publishedAt): static { $this->publishedAt = $publishedAt; return $this; }
}Nach der Migration (php bin/console doctrine:migrations:migrate) stellt API Platform automatisch die Endpunkte GET /books, POST /books, GET /books/{id}, PUT /books/{id}, PATCH /books/{id} und DELETE /books/{id} bereit — vollstaendig dokumentiert in Swagger UI unter /api.
Seit API Platform 4.1 wird UUID v7 fuer Ressourcen-IDs empfohlen. Im Gegensatz zu UUID v4 ist UUID v7 zeitbasiert und daher besser fuer Datenbankindizes geeignet. Die Konfiguration erfolgt ueber #[ORM\CustomIdGenerator(class: UuidV7Generator::class)]. Sequentielle IDs aus Sicherheitsgruenden vermeiden.
Serialisierungsgruppen zur Kontrolle exponierter Daten
Serialisierungsgruppen ermoelichen die praezise Steuerung, welche Felder bei Lese- oder Schreiboperationen zugelassen werden. Dies verhindert Massenassignment-Sicherheitsluecken und reduziert die uebertragene Datenmenge.
<?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 Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['user:list']]
),
new Get(
normalizationContext: ['groups' => ['user:read']]
),
new Post(
denormalizationContext: ['groups' => ['user:create']],
normalizationContext: ['groups' => ['user:read']]
),
new Put(
denormalizationContext: ['groups' => ['user:update']],
normalizationContext: ['groups' => ['user:read']]
),
]
)]
class User
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['user:list', 'user:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['user:list', 'user:read', 'user:create'])]
#[Assert\NotBlank, Assert\Email]
private ?string $email = null;
#[ORM\Column(length: 100)]
#[Groups(['user:list', 'user:read', 'user:create', 'user:update'])]
#[Assert\NotBlank]
private ?string $displayName = null;
// Only visible in the detailed read, never in the list
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['user:read', 'user:update'])]
private ?string $biography = null;
// Only writable, never readable (password hash)
#[Groups(['user:create', 'user:update'])]
#[Assert\NotBlank(groups: ['user:create'])]
#[Assert\Length(min: 8)]
private ?string $plainPassword = null;
// Internal: not exposed in any group
#[ORM\Column]
private string $password = '';
#[ORM\Column]
#[Groups(['user:read'])]
private \DateTimeImmutable $createdAt;
// Getters and setters...
}Diese Konfiguration stellt sicher, dass Passwoerter niemals in API-Antworten erscheinen, die E-Mail-Adresse nach der Erstellung nicht geaendert werden kann und die Biografie nur in der Detailansicht sichtbar ist.
Bereit für deine Symfony-Interviews?
Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.
State Processors fuer Geschaeftslogik
State Processors ersetzen in API Platform 4 die alten DataTransformers und PersistedDataProviders. Sie werden ausgefuehrt, wenn eine Ressource erstellt, aktualisiert oder geloescht wird, und ermoelichen die Einbindung von Geschaeftslogik vor oder nach der Datenbankoperation.
<?php
// src/State/UserPasswordHasher.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class UserPasswordHasher implements ProcessorInterface
{
public function __construct(
private readonly ProcessorInterface $processor,
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof User || !$data->getPlainPassword()) {
return $this->processor->process($data, $operation, $uriVariables, $context);
}
$hashedPassword = $this->passwordHasher->hashPassword(
$data,
$data->getPlainPassword()
);
$data->setPassword($hashedPassword);
$data->eraseCredentials();
return $this->processor->process($data, $operation, $uriVariables, $context);
}
}Fuer komplexere Geschaeftslogik kann ein dediziierter Processor mehrere Services einbinden.
<?php
// src/State/BookProcessor.php
namespace App\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Book;
use App\Service\SearchIndexService;
use App\Service\NotificationService;
use Psr\Log\LoggerInterface;
final class BookProcessor implements ProcessorInterface
{
public function __construct(
private readonly ProcessorInterface $processor,
private readonly SearchIndexService $searchIndex,
private readonly NotificationService $notifications,
private readonly LoggerInterface $logger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Book) {
return $this->processor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof Delete) {
$this->searchIndex->remove($data->getId());
$this->logger->info('Book removed from search index', ['id' => $data->getId()]);
return $this->processor->process($data, $operation, $uriVariables, $context);
}
$result = $this->processor->process($data, $operation, $uriVariables, $context);
// Post-persistence actions
$this->searchIndex->index($result);
if ($operation instanceof Post) {
$this->notifications->sendNewBookNotification($result);
}
$this->logger->info('Book processed', [
'id' => $result->getId(),
'operation' => $operation::class,
]);
return $result;
}
}Die Registrierung des Processors erfolgt per Attribut direkt in der Entitaet oder in der services.yaml.
State Providers fuer benutzerdefinierte Datenquellen
State Providers kontrollieren, wie Daten vor der Serialisierung abgerufen werden. Sie sind besonders nuetzlich fuer komplexe Abfragen, externe Datenquellen oder berechnete Ressourcen.
<?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;
final class PopularBooksProvider implements ProviderInterface
{
public function __construct(
private readonly BookRepository $bookRepository,
private readonly CacheInterface $cache,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|object|null
{
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$limit = (int) ($filters['itemsPerPage'] ?? 10);
$period = $filters['period'] ?? '30days';
$cacheKey = sprintf('popular_books_%s_%d_%d', $period, $page, $limit);
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($period, $page, $limit) {
$item->expiresAfter(3600); // 1 hour cache
return $this->bookRepository->findPopular(
period: $period,
page: $page,
limit: $limit
);
});
}
}Der Provider wird direkt im #[ApiResource]-Attribut referenziert.
<?php
// src/Entity/Book.php (excerpt)
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/books/popular',
provider: PopularBooksProvider::class,
),
new GetCollection(),
new Get(),
]
)]
class Book
{
// ...
}Erweiterte Validierung mit dynamischen Gruppen
API Platform integriert nativ den Symfony Validator. Fuer komplexe Szenarien ermoeglicht die Konfiguration von Validierungsgruppen eine kontextabhaengige Validierungslogik.
<?php
// src/Entity/Article.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\State\ArticleGroupsGenerator;
use App\Validator\Constraints as AppAssert;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new Post(
validationContext: ['groups' => ArticleGroupsGenerator::class]
),
new Put(
validationContext: ['groups' => ArticleGroupsGenerator::class]
),
]
)]
class Article
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(groups: ['Default', 'article:draft', 'article:publish'])]
#[Assert\Length(min: 5, max: 255, groups: ['Default', 'article:draft', 'article:publish'])]
private ?string $title = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Assert\NotBlank(groups: ['article:publish'])]
#[Assert\Length(min: 100, groups: ['article:publish'])]
private ?string $content = null;
#[ORM\Column(length: 20)]
#[Assert\Choice(choices: ['draft', 'review', 'published'])]
private string $status = 'draft';
#[ORM\Column(nullable: true)]
#[Assert\NotNull(groups: ['article:publish'])]
private ?\DateTimeImmutable $publishAt = null;
#[AppAssert\UniqueSlug(groups: ['Default', 'article:publish'])]
#[ORM\Column(length: 255, unique: true, nullable: true)]
private ?string $slug = null;
// Getters and setters...
}Der Groups Generator bestimmt dynamisch, welche Validierungsgruppen in Abhaengigkeit des Anwendungsstatus verwendet werden.
<?php
// src/State/ArticleGroupsGenerator.php
namespace App\State;
use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface;
use App\Entity\Article;
use Symfony\Component\HttpFoundation\Request;
final class ArticleGroupsGenerator implements ValidationGroupsGeneratorInterface
{
public function __invoke(object $object, ?Request $request = null): array
{
if (!$object instanceof Article) {
return ['Default'];
}
$groups = ['Default'];
// Add group based on target status
if ($object->getStatus() === 'published') {
$groups[] = 'article:publish';
} elseif ($object->getStatus() === 'draft') {
$groups[] = 'article:draft';
}
// Additional group if a publication date is set
if ($object->getPublishAt() !== null) {
$groups[] = 'article:scheduled';
}
return $groups;
}
}Bei grossen Datensaetzen koennen Constraints wie #[Assert\UniqueEntity] zu einem zusaetzlichen Datenbankquery pro Validierung fuehren. Fuer massenhafte Importe den Validator direkt mit gezielten Gruppen aufrufen oder die Validierung asynchron per Messenger-Job durchfuehren.
Filter fuer flexible Abfragen
API Platform bietet eine grosse Anzahl vorgefertigter Filter fuer Suche, Sortierung und Bereichsabfragen. Die Konfiguration erfolgt ausschliesslich ueber PHP-Attribute.
<?php
// src/Entity/Product.php
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\NumericFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ApiResource]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'partial', // LIKE %name%
'category' => 'exact', // Exact match
'brand' => 'start', // LIKE brand%
'description' => 'word_start', // Full-text word search
])]
#[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])]
#[ApiFilter(BooleanFilter::class, properties: ['inStock', 'featured'])]
#[ApiFilter(DateFilter::class, properties: ['createdAt', 'updatedAt'])]
#[ApiFilter(NumericFilter::class, properties: ['weight', 'rating'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'price', 'createdAt', 'rating'], 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(length: 100)]
private ?string $category = null;
#[ORM\Column(length: 100)]
private ?string $brand = null;
#[ORM\Column]
private float $price = 0.0;
#[ORM\Column]
private int $stock = 0;
#[ORM\Column]
private bool $inStock = true;
#[ORM\Column]
private bool $featured = false;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
// Getters and setters...
}Nach dieser Konfiguration stehen folgende Abfragen automatisch zur Verfuegung:
GET /products?name=wireless&category=electronics&inStock=true&price[gte]=50&price[lte]=500&sort[price]=ascAPI Platform generiert die entsprechenden Swagger-Parameter automatisch und validiert Eingaben vor der Ausfuehrung.
Beziehungen und Subressourcen
API Platform verarbeitet Doctrine-Beziehungen nativ und stellt sie als IRIs (Internationalized Resource Identifiers) in JSON-LD-Antworten dar. Subressourcen ermoelichen verschachtelte Endpunkte.
<?php
// src/Entity/Author.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
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(
operations: [new GetCollection(), new Get()],
normalizationContext: ['groups' => ['author:read']]
)]
class Author
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['author:read', 'book:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[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
// src/Entity/Book.php (relations)
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
#[ApiResource(
uriTemplate: '/authors/{authorId}/books',
uriVariables: [
'authorId' => new Link(
fromClass: Author::class,
fromProperty: 'books'
)
],
operations: [new GetCollection()],
normalizationContext: ['groups' => ['book:read']]
)]
#[ApiResource(
operations: [
new GetCollection(),
new Post(),
new Get(),
new Put(),
new Patch(),
new Delete(),
],
normalizationContext: ['groups' => ['book:read']],
denormalizationContext: ['groups' => ['book:write']],
)]
class Book
{
// ...
#[ORM\ManyToOne(inversedBy: 'books')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['book:read', 'book:write'])]
private ?Author $author = null;
}Dadurch wird der Endpunkt GET /authors/{authorId}/books automatisch erstellt, der nur die Buecher des angegebenen Autors zurueckgibt.
Sicherheit und Zugriffskontrolle
API Platform integriert Symfonys Security-Komponente ueber security- und securityPostDenormalize-Attribute auf Ressourcen- und Operationsebene.
<?php
// src/Entity/Order.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity]
#[ApiResource(
operations: [
// Only authenticated users see their orders
new GetCollection(
security: 'is_granted("ROLE_USER")',
securityMessage: 'Authentication required to view orders.',
),
// Admins see all orders
new GetCollection(
uriTemplate: '/admin/orders',
security: 'is_granted("ROLE_ADMIN")',
),
new Post(
security: 'is_granted("ROLE_USER")',
),
new Get(
// The user can only access their own orders
security: 'is_granted("ROLE_ADMIN") or object.getCustomer() == user',
securityMessage: 'Access denied.',
),
new Patch(
// Only admins can modify orders
security: 'is_granted("ROLE_ADMIN")',
// Or the owner, but only if the order is still in "pending" status
securityPostDenormalize: 'is_granted("ROLE_ADMIN") or (object.getCustomer() == user and object.getStatus() == "pending")',
),
new Delete(
security: 'is_granted("ROLE_ADMIN")',
),
],
normalizationContext: ['groups' => ['order:read']],
denormalizationContext: ['groups' => ['order:write']],
)]
class Order
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['order:read'])]
private ?int $id = null;
#[ORM\ManyToOne]
#[Groups(['order:read', 'admin:read'])]
private ?User $customer = null;
#[ORM\Column(length: 20)]
#[Groups(['order:read'])]
private string $status = 'pending';
#[ORM\Column]
#[Groups(['order:read'])]
private float $total = 0.0;
public function getCustomer(): ?User { return $this->customer; }
public function getStatus(): string { return $this->status; }
// Other getters and setters...
}Fuer komplexe Berechtigungslogik empfiehlt sich die Verwendung von Symfony Voters, die aus den Security-Expressions heraus aufgerufen werden koennen.
Automatisierte API-Tests
API Platform liefert eine Testinfrastruktur, die auf Symfonys HttpKernel aufbaut und einen vollstaendigen API-Testzyklus mit JSON-Assertions ermoeglicht.
<?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, Factories;
public function testGetCollection(): void
{
BookFactory::createMany(5);
$response = static::createClient()->request('GET', '/api/books');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains([
'@context' => '/api/contexts/Book',
'@type' => 'hydra:Collection',
'hydra:totalItems' => 5,
]);
$this->assertCount(5, $response->toArray()['hydra:member']);
}
public function testCreateBook(): void
{
$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
$token = $this->getToken(['username' => $user->getEmail(), 'password' => 'password']);
static::createClient()->request('POST', '/api/books', [
'auth_bearer' => $token,
'json' => [
'title' => 'Clean Code',
'isbn' => '9780132350884',
'price' => 35.99,
'publishedAt' => '2008-08-01T00:00:00+00:00',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains([
'@type' => 'Book',
'title' => 'Clean Code',
'isbn' => '9780132350884',
'price' => 35.99,
]);
}
public function testUpdateBook(): void
{
$book = BookFactory::createOne(['price' => 25.00]);
$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
$token = $this->getToken(['username' => $user->getEmail(), 'password' => 'password']);
static::createClient()->request('PATCH', '/api/books/' . $book->getId(), [
'auth_bearer' => $token,
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['price' => 29.99],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['price' => 29.99]);
}
public function testDeleteBookForbiddenForUser(): void
{
$book = BookFactory::createOne();
$user = UserFactory::createOne(['roles' => ['ROLE_USER']]);
$token = $this->getToken(['username' => $user->getEmail(), 'password' => 'password']);
static::createClient()->request('DELETE', '/api/books/' . $book->getId(), [
'auth_bearer' => $token,
]);
$this->assertResponseStatusCodeSame(403);
}
private function getToken(array $credentials): string
{
$response = static::createClient()->request('POST', '/api/auth', ['json' => $credentials]);
return $response->toArray()['token'];
}
}Fazit
API Platform 4 mit Symfony 7 bietet ein vollstaendiges Oekosystem fuer den Aufbau professioneller APIs. Die Kombination aus deklarativer Konfiguration, leistungsstarken State Processors und Providers sowie der nahtlosen Integration mit dem Symfony-Ecosystem macht es zur ersten Wahl fuer skalierbare PHP-APIs.
Checkliste fuer eine produktionsreife API Platform API
#[ApiResource]mit expliziten Operationen statt Default-Konfiguration verwenden- Serialisierungsgruppen fuer jede Lese-/Schreiboperation definieren
- Passwoerter und sensible Daten aus allen Serialisierungsgruppen ausschliessen
- State Processors fuer Geschaeftslogik einsetzen, niemals direkt im Controller
- State Providers fuer benutzerdefinierte oder gecachte Datenquellen verwenden
- Dynamische Validierungsgruppen fuer statusabhaengige Regeln konfigurieren
- Filter ueber Attribute definieren und nie manuell in Providers implementieren
- Sicherheitsregeln mit
securityauf jeder Operation festlegen - API-Tests mit
ApiTestCaseund Foundry Factories abdecken - UUID v7 fuer Ressourcen-IDs anstelle sequenzieller Integer verwenden
Fang an zu üben!
Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.
Die konsequente Anwendung dieser Patterns resultiert in APIs, die sowohl sicher als auch wartbar sind und den automatisch generierten OpenAPI-Dokumentationsvorteil vollstaendig ausnutzen. API Platform nimmt Entwicklern die repetitive Arbeit ab und laesst Raum fuer die Implementierung eigentlicher Geschaeftslogik.
Tags
Teilen
