# Symfony 7: API Platformとベストプラクティス > Symfony 7とAPI Platform 4を使ったプロフェッショナルなREST APIの構築方法を解説します。State Processor、State Provider、シリアライゼーショングループ、高度なバリデーション、セキュリティ設定まで、実践的なベストプラクティスを網羅しています。 - Published: 2026-01-12 - Updated: 2026-04-07 - Author: SharpSkill - Tags: symfony, api platform, php, rest api, api development - Reading time: 14 min --- API Platform 4は、Symfony 7とともにREST APIおよびGraphQL APIの構築方法を根本から変えました。この新バージョンでは、関心の分離・State ProviderとProcessorのシンプルな設計・Symfony Object Mapperのネイティブ統合という3つの哲学が貫かれています。プロフェッショナルなAPIの構築が、かつてないほど直感的になっています。 > **API Platform 4.2の新機能** > > バージョン4.2では、JSON Streamerが導入され、最大+32% RPSのパフォーマンス向上を実現しています。フィルターシステムの刷新、コアを変更せずに操作をカスタマイズできるMutator、そしてSymfony 7・8のネイティブサポートが追加されました。 ## インストールと初期設定 API PlatformはSymfony Flexを使って数コマンドでインストールできます。デフォルト設定はほとんどのユースケースをカバーしており、完全なカスタマイズも可能です。 ```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は、ルート・OpenAPIドキュメント・Swagger UIインターフェース(`/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 ``` この設定により、シリアライゼーション形式・グローバルなページネーション・APIドキュメントのメタデータが定義されます。 ## シンプルなAPIリソースの作成 `#[ApiResource]`アトリビュートを使うと、DoctrineエンティティをRESTリソースとして公開できます。API PlatformはCRUDエンドポイント・OpenAPIドキュメント・基本的なバリデーションを自動生成します。 ```php '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つのエンドポイントが自動生成されます。 > **UUID v7の推奨** > > API PlatformはUUID v7を識別子としてネイティブサポートしています。この手法により、セキュリティの向上(予測不可能な識別子)とパフォーマンスの改善(作成日時による自然なソート)が実現します。 ## 公開データを制御するシリアライゼーショングループ シリアライゼーショングループを使うと、読み取り(正規化)と書き込み(逆正規化)でどのプロパティを公開するかを精密に制御できます。この分離はAPIのセキュリティと柔軟性において不可欠です。 ```php ['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`はレスポンスに含まれることなく、作成時・更新時の書き込みのみで受け付けられます。 ## ビジネスロジックを担うState Processor State Processorは永続化操作をインターセプトし、ビジネスロジックを追加します。API Platform 4では、アトリビュートベースの依存性注入により作成が大幅に簡素化されました。 ```php */ 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 */ 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 */ 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 ['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 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 '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 ['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 ['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 customer; } } ``` `object.getCustomer() == user`というエクスプレッションにより、現在のエンティティとログインユーザーにアクセスし、きめ細かいチェックが実現します。 ## APIの自動テスト API Platformはエンドポイントのテストを容易にするPHPUnitトレイトを提供しています。 ```php 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が、初日からプロダクション品質で構築できます。 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ja/blog/symfony/symfony-7-api-platform-best-practices