Symfony 7: API Platform dan Praktik Terbaik

Panduan lengkap membangun REST API profesional dengan Symfony 7 dan API Platform 4. State Providers, Processors, validasi, dan serialisasi dijelaskan dengan contoh praktis.

Panduan Symfony 7 API Platform 4: Praktik Terbaik REST API dengan PHP

API Platform 4 merevolusi pembuatan REST API dan GraphQL dengan Symfony 7. Versi terbaru ini membawa filosofi yang dirancang ulang secara menyeluruh: pemisahan tanggung jawab yang jelas, State Providers dan Processors yang disederhanakan, serta integrasi native Symfony Object Mapper. Membangun API profesional dengan symfony-7-api-platform belum pernah semudah dan seintuitif ini.

Apa yang Baru di API Platform 4.2

Versi 4.2 memperkenalkan JSON Streamer untuk peningkatan performa hingga +32% RPS, sistem filter yang dirancang ulang, serta Mutators untuk menyesuaikan operasi tanpa menyentuh inti framework. Dukungan Symfony 7 dan 8 bersifat native.

Instalasi dan Konfigurasi Awal

API Platform diinstal dalam beberapa perintah menggunakan Symfony Flex. Konfigurasi default mencakup sebagian besar kasus penggunaan sekaligus tetap sepenuhnya dapat dikustomisasi.

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 secara otomatis mengkonfigurasi routes, dokumentasi OpenAPI, serta antarmuka Swagger UI yang dapat diakses di /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

Konfigurasi ini mendefinisikan format serialisasi, paginasi global, dan metadata dokumentasi API.

Membuat API Resource Sederhana

Atribut #[ApiResource] mengekspos entitas Doctrine sebagai resource REST. API Platform secara otomatis menghasilkan endpoint CRUD, dokumentasi OpenAPI, dan validasi dasar.

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

Entitas ini menghasilkan enam endpoint: GET /api/books, POST /api/books, GET /api/books/{id}, PUT /api/books/{id}, PATCH /api/books/{id}, dan DELETE /api/books/{id}.

UUID v7 Direkomendasikan

API Platform mendukung UUID v7 secara native sebagai identifier. Pendekatan ini meningkatkan keamanan (identifier yang tidak dapat diprediksi) dan performa (pengurutan alami berdasarkan tanggal pembuatan).

Serialization Groups untuk Mengontrol Data yang Diekspos

Serialization groups mendefinisikan secara presisi properti mana yang diekspos untuk pembacaan (normalization) dan penulisan (denormalization). Pemisahan ini esensial untuk keamanan dan fleksibilitas 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;
    }
}

Dengan konfigurasi ini, plainPassword tidak pernah diekspos dalam respons tetapi dapat dikirimkan saat pembuatan atau pembaruan akun.

Siap menguasai wawancara Symfony Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

State Processors untuk Logika Bisnis

State Processors mencegat operasi persistensi untuk menambahkan logika bisnis. API Platform 4 menyederhanakan pembuatannya melalui dependency injection berbasis atribut.

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

Pola komposisi ini memungkinkan penambahan logika apa pun—pengiriman email, event, logging—sambil mempertahankan perilaku persistensi standar.

Processor dengan Logika Kondisional

Sebuah processor dapat mengadaptasi perilakunya berdasarkan jenis operasi yang sedang dijalankan.

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 untuk Sumber Data Kustom

State Providers mengambil data dari sumber mana pun: API eksternal, cache, file, atau logika bisnis yang kompleks. Fleksibilitas ini merupakan keunggulan utama arsitektur API Platform 4.

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

Provider ini digunakan pada operasi khusus yang didefinisikan secara terpisah di entitas.

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

Validasi Lanjutan dengan Grup Dinamis

API Platform mengintegrasikan komponen Validator Symfony secara native. Grup validasi dapat bervariasi berdasarkan operasi atau konteks, memberikan kontrol granular atas aturan bisnis.

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

Validasi Dinamis dengan Service

Untuk aturan validasi yang kompleks, generator grup kustom menawarkan fleksibilitas total tanpa batasan konfigurasi statis.

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

Validasi yang kompleks dapat berdampak pada performa. Untuk impor massal, pertimbangkan untuk menonaktifkan sementara validasi tertentu atau menggunakan constraint asinkron.

Filter untuk Query yang Fleksibel

API Platform 4.2 merancang ulang sistem filter secara menyeluruh dengan pemisahan tanggung jawab yang jelas. Filter memungkinkan klien API untuk mencari dan mengurutkan data dengan mudah.

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

Filter-filter ini secara otomatis menghasilkan dokumentasi OpenAPI dan mengaktifkan query seperti:

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

Relasi dan Subresource

API Platform menangani relasi antar entitas dengan elegan melalui opsi serialisasi dan subresource yang terkonfigurasi dengan baik.

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

Keamanan dan Kontrol Akses

API Platform terintegrasi secara mulus dengan sistem keamanan Symfony. Voter dan ekspresi keamanan mengontrol akses ke resource dengan granularitas tinggi per operasi.

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

Ekspresi object.getCustomer() == user memberikan akses ke entitas saat ini dan pengguna yang terkoneksi untuk pemeriksaan yang sangat granular.

Pengujian API Secara Otomatis

API Platform menyediakan trait PHPUnit untuk menguji endpoint dengan mudah dan efisien. Setiap fitur API sebaiknya dilindungi oleh test fungsional.

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

Kesimpulan

API Platform 4 dengan Symfony 7 merepresentasikan standar terkini dalam pembuatan REST API profesional dengan PHP. Pemisahan yang jelas antara State Providers (pembacaan) dan State Processors (penulisan), dikombinasikan dengan serialization groups dan sistem validasi, memungkinkan pembangunan API yang kokoh dan mudah dipelihara.

Checklist untuk API Berkualitas

  • Gunakan serialization groups yang berbeda untuk pembacaan dan penulisan
  • Implementasikan State Processors untuk logika bisnis (hashing kata sandi, notifikasi)
  • Konfigurasikan filter untuk pencarian dan pengurutan data
  • Terapkan validasi per operasi dengan grup yang sesuai
  • Amankan endpoint dengan ekspresi security per operasi
  • Tulis test fungsional untuk setiap endpoint penting
  • Dokumentasikan API melalui metadata OpenAPI

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Filosofi API Platform 4 mendorong komposisi di atas pewarisan, serta konfigurasi di atas konvensi. Hasilnya adalah API yang skalabel, mudah diuji, dan sesuai standar REST/JSON-LD—siap produksi sejak hari pertama pengembangan.

Tag

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

Bagikan

Artikel terkait