Symfony 7: API Platformとベストプラクティス
Symfony 7とAPI Platform 4を使ったプロフェッショナルなREST APIの構築方法を解説します。State Processor、State Provider、シリアライゼーショングループ、高度なバリデーション、セキュリティ設定まで、実践的なベストプラクティスを網羅しています。

API Platform 4は、Symfony 7とともにREST APIおよびGraphQL APIの構築方法を根本から変えました。この新バージョンでは、関心の分離・State ProviderとProcessorのシンプルな設計・Symfony Object Mapperのネイティブ統合という3つの哲学が貫かれています。プロフェッショナルなAPIの構築が、かつてないほど直感的になっています。
バージョン4.2では、JSON Streamerが導入され、最大+32% RPSのパフォーマンス向上を実現しています。フィルターシステムの刷新、コアを変更せずに操作をカスタマイズできるMutator、そしてSymfony 7・8のネイティブサポートが追加されました。
インストールと初期設定
API PlatformはSymfony Flexを使って数コマンドでインストールできます。デフォルト設定はほとんどのユースケースをカバーしており、完全なカスタマイズも可能です。
# 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 apiSymfony Flexは、ルート・OpenAPIドキュメント・Swagger UIインターフェース(/apiでアクセス可能)を自動的に設定します。
# 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この設定により、シリアライゼーション形式・グローバルなページネーション・APIドキュメントのメタデータが定義されます。
シンプルなAPIリソースの作成
#[ApiResource]アトリビュートを使うと、DoctrineエンティティをRESTリソースとして公開できます。API PlatformはCRUDエンドポイント・OpenAPIドキュメント・基本的なバリデーションを自動生成します。
<?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;
}
}このエンティティから、GET /api/books、POST /api/books、GET /api/books/{id}、PUT /api/books/{id}、PATCH /api/books/{id}、DELETE /api/books/{id}の6つのエンドポイントが自動生成されます。
API PlatformはUUID v7を識別子としてネイティブサポートしています。この手法により、セキュリティの向上(予測不可能な識別子)とパフォーマンスの改善(作成日時による自然なソート)が実現します。
公開データを制御するシリアライゼーショングループ
シリアライゼーショングループを使うと、読み取り(正規化)と書き込み(逆正規化)でどのプロパティを公開するかを精密に制御できます。この分離はAPIのセキュリティと柔軟性において不可欠です。
<?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;
}
}この設定により、plainPasswordはレスポンスに含まれることなく、作成時・更新時の書き込みのみで受け付けられます。
Symfonyの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
ビジネスロジックを担うState Processor
State Processorは永続化操作をインターセプトし、ビジネスロジックを追加します。API Platform 4では、アトリビュートベースの依存性注入により作成が大幅に簡素化されました。
<?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);
}
}このコンポジションパターンにより、メール送信・イベント発行・ロギングなどのロジックを追加しながら、標準的な永続化動作を維持できます。
条件付きロジックを持つProcessor
Processorは操作の種類に応じて動作を変えることができます。
<?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 Provider
State Providerは、外部API・キャッシュ・ファイル・複雑なビジネスロジックなど、あらゆるソースからデータを取得します。
<?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);
});
}
}このProviderは専用のオペレーションで使用されます。
<?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
{
// ...
}ダイナミックグループによる高度なバリデーション
API PlatformはSymfonyのValidatorコンポーネントをネイティブに統合しています。バリデーショングループは操作やコンテキストに応じて動的に変化させることができます。
<?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...
}サービスを使ったダイナミックバリデーション
複雑なバリデーションルールには、カスタムグループジェネレーターが完全な柔軟性を提供します。
<?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;
}
}複雑なバリデーションはパフォーマンスに影響する場合があります。大量インポート時は、一部のバリデーションを一時的に無効化するか、非同期制約の利用を検討してください。
柔軟なクエリのためのフィルター
API Platform 4.2ではフィルターシステムが完全に刷新され、関心の分離が明確になりました。フィルターを使うと、APIクライアントがデータを検索・ソートできるようになります。
<?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...
}これらのフィルターはOpenAPIドキュメントを自動生成し、以下のようなクエリを可能にします。
GET /api/products?name=phone&price[gte]=100&price[lte]=500&isActive=true&sort[price]=ascリレーションとサブリソース
API Platformはシリアライゼーションオプションとサブリソースを使って、エンティティ間のリレーションをエレガントに扱います。
<?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
// 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...
}セキュリティとアクセス制御
API PlatformはSymfonyのセキュリティシステムとシームレスに統合されます。VoterとSecurityエクスプレッションでリソースへのアクセスを制御できます。
<?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;
}
}object.getCustomer() == userというエクスプレッションにより、現在のエンティティとログインユーザーにアクセスし、きめ細かいチェックが実現します。
APIの自動テスト
API Platformはエンドポイントのテストを容易にするPHPUnitトレイトを提供しています。
<?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';
}
}まとめ
API Platform 4とSymfony 7は、PHPでプロフェッショナルなREST APIを構築するための最先端の組み合わせです。State Provider(読み取り)とState Processor(書き込み)の明確な分離、シリアライゼーショングループ、バリデーションシステムを組み合わせることで、堅牢で保守性の高いAPIが実現します。
品質の高いAPIのチェックリスト
- 読み取りと書き込みで異なるシリアライゼーショングループを使用する
- ビジネスロジック(パスワードハッシュ化・通知など)にState Processorを実装する
- 検索・ソートのためにフィルターを設定する
- グループを使ってオペレーションごとのバリデーションを適用する
- セキュリティエクスプレッションでエンドポイントを保護する
- 各エンドポイントに対して機能テストを記述する
- OpenAPIメタデータを通じてAPIをドキュメント化する
今すぐ練習を始めましょう!
面接シミュレーターと技術テストで知識をテストしましょう。
API Platform 4の設計思想は、継承よりもコンポジション、規約よりも設定を重視しています。その結果、REST/JSON-LD標準に準拠したスケーラブルでテスト可能なAPIが、初日からプロダクション品質で構築できます。
タグ
共有
