# Pertanyaan Wawancara Symfony: Top 25 di 2026 > 25 pertanyaan wawancara Symfony paling sering ditanyakan. Arsitektur, Doctrine ORM, service, keamanan, form, dan testing dengan jawaban detail dan contoh kode. - Published: 2026-02-02 - Updated: 2026-04-27 - Author: SharpSkill - Tags: symfony, php, interview, doctrine, technical interview - Reading time: 24 min --- Wawancara Symfony menguji penguasaan framework PHP profesional yang menjadi referensi, pemahaman arsitektur berbasis komponen, ORM Doctrine, dan kemampuan membangun aplikasi yang kokoh dan dapat diskalakan. Panduan ini membahas 25 pertanyaan paling sering ditanyakan, dari fundamental Symfony hingga pola produksi tingkat lanjut. > **Tips wawancara** > > Recruiter menghargai kandidat yang memahami filosofi Symfony: pemisahan melalui service, konfigurasi eksplisit, dan kepatuhan pada standar PSR. Mampu menjelaskan keputusan arsitektural framework membuat perbedaan. ## Fundamental Symfony ### Pertanyaan 1: Jelaskan siklus hidup request di Symfony Siklus hidup request Symfony melewati HTTP Kernel dan menggunakan sistem event untuk memungkinkan ekstensi pada setiap langkah. Memahaminya sangat penting untuk debugging dan menyesuaikan perilaku aplikasi. ```php // public/index.php // Titik masuk untuk semua request HTTP use App\Kernel; require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; // Symfony Runtime menangani bootstrap return function (array $context) { return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); }; ``` ```php // src/Kernel.php // Kernel mengorkestrasi pemrosesan request namespace App; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\HttpKernel\Kernel as BaseKernel; class Kernel extends BaseKernel { use MicroKernelTrait; // Kernel memuat bundle dan mengonfigurasi container // Event kunci dalam siklus hidup: // 1. kernel.request - Sebelum routing // 2. kernel.controller - Setelah resolusi controller // 3. kernel.view - Jika controller tidak mengembalikan Response // 4. kernel.response - Sebelum mengirim respons // 5. kernel.terminate - Setelah dikirim (tugas asinkron) } ``` Siklus lengkap: `index.php` → Runtime → Kernel → HttpKernel → EventDispatcher (kernel.request) → Router → Controller → EventDispatcher (kernel.response) → Response. Setiap langkah dapat diintersep melalui Event Subscribers. ### Pertanyaan 2: Apa itu Service Container dan Dependency Injection di Symfony? Service Container (atau DIC, Dependency Injection Container) adalah jantung Symfony. Ia mengelola pembuatan instance, konfigurasi, dan injeksi semua service aplikasi. ```php // src/Service/PaymentService.php // Service dengan dependency yang diinjeksikan secara otomatis namespace App\Service; use App\Repository\OrderRepository; use Psr\Log\LoggerInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class PaymentService { public function __construct( private readonly HttpClientInterface $stripeClient, // Klien HTTP yang sudah dikonfigurasi private readonly OrderRepository $orderRepository, // Repository Doctrine private readonly LoggerInterface $logger, // Logger PSR-3 private readonly string $stripeApiKey, // Parameter yang diinjeksikan ) {} public function processPayment(int $orderId, float $amount): bool { $order = $this->orderRepository->find($orderId); try { $response = $this->stripeClient->request('POST', '/charges', [ 'body' => [ 'amount' => $amount * 100, 'currency' => 'eur', 'source' => $order->getPaymentToken(), ], ]); $order->markAsPaid($response->toArray()['id']); $this->orderRepository->save($order, true); return true; } catch (\Exception $e) { $this->logger->error('Payment failed', [ 'order' => $orderId, 'error' => $e->getMessage(), ]); return false; } } } ``` ```yaml # config/services.yaml # Konfigurasi service services: _defaults: autowire: true # Injeksi otomatis berdasarkan type-hint autoconfigure: true # Konfigurasi tag otomatis App\: resource: '../src/' exclude: - '../src/DependencyInjection/' - '../src/Entity/' - '../src/Kernel.php' # Konfigurasi eksplisit dengan parameter App\Service\PaymentService: arguments: $stripeClient: '@stripe.client' $stripeApiKey: '%env(STRIPE_API_KEY)%' ``` Autowiring secara otomatis menyelesaikan dependency berdasarkan type-hint. Parameter skalar memerlukan konfigurasi eksplisit. ### Pertanyaan 3: Apa perbedaan antara Bundle dan komponen Symfony? Bundle adalah paket yang dapat digunakan kembali yang mengintegrasikan fungsionalitas ke dalam aplikasi Symfony. Komponen adalah library PHP mandiri yang dapat digunakan tanpa Symfony. ```php // src/MyBundle/MyBundle.php // Struktur Bundle kustom namespace App\MyBundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; class MyBundle extends AbstractBundle { // Memuat konfigurasi bundle public function loadExtension( array $config, ContainerConfigurator $container, ContainerBuilder $builder ): void { // Memuat service bundle $container->import('../config/services.yaml'); // Konfigurasi kondisional if ($config['feature_enabled']) { $container->services() ->set('my_bundle.feature_service', FeatureService::class) ->autowire(); } } // Konfigurasi default bundle public function configure(DefinitionConfigurator $definition): void { $definition->rootNode() ->children() ->booleanNode('feature_enabled')->defaultTrue()->end() ->scalarNode('api_key')->isRequired()->end() ->end(); } } ``` ```php // Penggunaan komponen tanpa Symfony // Komponen adalah library PHP mandiri use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; // Dapat digunakan di proyek PHP apa pun $request = Request::createFromGlobals(); $response = new Response('Hello World', 200); $response->send(); ``` Bundle merangkum konfigurasi, service, dan resource. Komponen adalah alat tingkat rendah yang dapat digunakan kembali di mana saja. ### Pertanyaan 4: Bagaimana cara kerja Event Subscribers di Symfony? Event Subscribers memungkinkan bereaksi pada event framework atau aplikasi, memisahkan logika bisnis dari kode utama. ```php // src/EventSubscriber/ApiExceptionSubscriber.php namespace App\EventSubscriber; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\KernelEvents; class ApiExceptionSubscriber implements EventSubscriberInterface { // Mendeklarasikan event yang didengarkan dan prioritasnya public static function getSubscribedEvents(): array { return [ // Prioritas tinggi (dijalankan sebelum yang lain) KernelEvents::EXCEPTION => ['onKernelException', 100], KernelEvents::RESPONSE => ['onKernelResponse', 0], ]; } public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); $request = $event->getRequest(); // Hanya menangani request API if (!str_starts_with($request->getPathInfo(), '/api')) { return; } $statusCode = $exception instanceof HttpExceptionInterface ? $exception->getStatusCode() : 500; $response = new JsonResponse([ 'error' => true, 'message' => $exception->getMessage(), 'code' => $statusCode, ], $statusCode); // Mengganti respons dengan JSON kita $event->setResponse($response); } public function onKernelResponse(\Symfony\Component\HttpKernel\Event\ResponseEvent $event): void { // Menambahkan header kustom $event->getResponse()->headers->set('X-Api-Version', '1.0'); } } ``` Event Subscribers ditemukan secara otomatis berkat autoconfigure. Prioritas menentukan urutan eksekusi (lebih tinggi = dijalankan lebih dulu). ## Doctrine ORM ### Pertanyaan 5: Jelaskan relasi Doctrine dan perbedaannya Doctrine menawarkan beberapa jenis relasi untuk memodelkan asosiasi antar entitas. Setiap jenis berdampak pada query dan performa. ```php // src/Entity/User.php namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: UserRepository::class)] class User { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; // Relasi OneToOne: satu user memiliki satu profil #[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])] private ?Profile $profile = null; // Relasi OneToMany: satu user memiliki banyak artikel #[ORM\OneToMany(mappedBy: 'author', targetEntity: Article::class, orphanRemoval: true)] private Collection $articles; // Relasi ManyToMany: banyak user memiliki banyak role #[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')] #[ORM\JoinTable(name: 'user_roles')] private Collection $roles; public function __construct() { // Inisialisasi koleksi yang wajib $this->articles = new ArrayCollection(); $this->roles = new ArrayCollection(); } public function addArticle(Article $article): static { if (!$this->articles->contains($article)) { $this->articles->add($article); $article->setAuthor($this); // Sinkronisasi dua arah } return $this; } public function removeArticle(Article $article): static { if ($this->articles->removeElement($article)) { if ($article->getAuthor() === $this) { $article->setAuthor(null); } } return $this; } } ``` ```php // src/Entity/Article.php #[ORM\Entity(repositoryClass: ArticleRepository::class)] class Article { // Relasi ManyToOne: banyak artikel dimiliki oleh satu penulis #[ORM\ManyToOne(inversedBy: 'articles')] #[ORM\JoinColumn(nullable: false)] private ?User $author = null; // ManyToMany dengan atribut tambahan via entitas pivot #[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticleTag::class)] private Collection $articleTags; } ``` Relasi dua arah memerlukan sinkronisasi manual. Sisi "owning" (dengan JoinColumn/JoinTable) mengontrol persistence. ### Pertanyaan 6: Apa itu masalah N+1 dan bagaimana cara mengatasinya dengan Doctrine? Masalah N+1 terjadi ketika satu query utama menghasilkan N query tambahan untuk memuat relasi. Ini adalah penyebab paling umum kelambatan dalam aplikasi Symfony. ```php // src/Repository/ArticleRepository.php namespace App\Repository; use App\Entity\Article; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class ArticleRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Article::class); } // BURUK: N+1 query saat mengakses penulis public function findAllBad(): array { return $this->findAll(); // + 1 query per artikel untuk memuat penulis } // BAIK: JOIN dengan eager fetch public function findAllWithAuthor(): array { return $this->createQueryBuilder('a') ->addSelect('u') // SELECT juga penulis ->leftJoin('a.author', 'u') // JOIN pada relasi ->orderBy('a.publishedAt', 'DESC') ->getQuery() ->getResult(); } // BAIK: beberapa JOIN untuk beberapa relasi public function findAllWithDetails(): array { return $this->createQueryBuilder('a') ->addSelect('u', 'c', 't') // SELECT semua relasi ->leftJoin('a.author', 'u') ->leftJoin('a.comments', 'c') ->leftJoin('a.tags', 't') ->where('a.status = :status') ->setParameter('status', 'published') ->orderBy('a.publishedAt', 'DESC') ->getQuery() ->getResult(); } // BAIK: pemuatan batch untuk daftar besar public function findAllOptimized(): array { $query = $this->createQueryBuilder('a') ->getQuery(); // Memuat relasi dalam batch 100 $query->setFetchMode(Article::class, 'author', ClassMetadata::FETCH_BATCH); return $query->getResult(); } } ``` Symfony Profiler dengan panel Doctrine membantu mendeteksi masalah N+1. Jumlah query tampil di Web Debug Toolbar. ### Pertanyaan 7: Bagaimana cara membuat Query Extensions dan filter Doctrine? Query Extensions dan filter Doctrine memungkinkan menerapkan kondisi secara otomatis ke semua query — ideal untuk multi-tenancy atau soft delete. ```php // src/Doctrine/Extension/CurrentUserExtension.php // Extension API Platform untuk memfilter berdasarkan user secara otomatis namespace App\Doctrine\Extension; use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; use App\Entity\Article; use Doctrine\ORM\QueryBuilder; use Symfony\Bundle\SecurityBundle\Security; final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface { public function __construct( private readonly Security $security, ) {} public function applyToCollection( QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = [] ): void { $this->addWhere($queryBuilder, $resourceClass); } public function applyToItem( QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = [] ): void { $this->addWhere($queryBuilder, $resourceClass); } private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void { // Hanya berlaku untuk Article if ($resourceClass !== Article::class) { return; } // Admin melihat semua if ($this->security->isGranted('ROLE_ADMIN')) { return; } $user = $this->security->getUser(); $rootAlias = $queryBuilder->getRootAliases()[0]; // Filter otomatis berdasarkan penulis $queryBuilder ->andWhere(sprintf('%s.author = :current_user', $rootAlias)) ->setParameter('current_user', $user); } } ``` ```php // src/Doctrine/Filter/SoftDeleteFilter.php // Filter Doctrine global untuk mengecualikan item yang dihapus namespace App\Doctrine\Filter; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query\Filter\SQLFilter; class SoftDeleteFilter extends SQLFilter { public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string { // Cek apakah entitas memiliki field deletedAt if (!$targetEntity->hasField('deletedAt')) { return ''; } return sprintf('%s.deleted_at IS NULL', $targetTableAlias); } } ``` ```yaml # config/packages/doctrine.yaml doctrine: orm: filters: soft_delete: class: App\Doctrine\Filter\SoftDeleteFilter enabled: true ``` Filter berlaku pada level SQL, extension pada level QueryBuilder. Nonaktifkan sementara dengan `$em->getFilters()->disable('soft_delete')`. ## Keamanan Symfony ### Pertanyaan 8: Bagaimana cara kerja sistem keamanan Symfony? Komponen Security Symfony mengelola autentikasi (siapa user) dan otorisasi (apa yang boleh ia lakukan) melalui arsitektur yang dapat diperluas. ```yaml # config/packages/security.yaml security: password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' providers: app_user_provider: entity: class: App\Entity\User property: email firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false api: pattern: ^/api stateless: true jwt: ~ # Lexik JWT Bundle main: lazy: true provider: app_user_provider form_login: login_path: app_login check_path: app_login enable_csrf: true logout: path: app_logout remember_me: secret: '%kernel.secret%' lifetime: 604800 access_control: - { path: ^/api/login, roles: PUBLIC_ACCESS } - { path: ^/api, roles: ROLE_USER } - { path: ^/admin, roles: ROLE_ADMIN } ``` ```php // src/Security/CustomAuthenticator.php // Authenticator kustom untuk logika spesifik namespace App\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; class ApiKeyAuthenticator extends AbstractAuthenticator { public function supports(Request $request): ?bool { // Authenticator ini hanya menangani request dengan X-API-KEY return $request->headers->has('X-API-KEY'); } public function authenticate(Request $request): Passport { $apiKey = $request->headers->get('X-API-KEY'); if (null === $apiKey) { throw new AuthenticationException('No API key provided'); } // UserBadge memuat user berdasarkan identifier return new SelfValidatingPassport( new UserBadge($apiKey, function (string $apiKey) { // Logika memuat user berdasarkan API key return $this->userRepository->findByApiKey($apiKey); }) ); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { // null = lanjutkan request secara normal return null; } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { return new JsonResponse([ 'error' => $exception->getMessage(), ], Response::HTTP_UNAUTHORIZED); } } ``` Arsitektur keamanan bertumpu pada Firewalls (konfigurasi), Authenticators (autentikasi), dan Voters (otorisasi). ### Pertanyaan 9: Bagaimana cara mengimplementasikan Voter untuk otorisasi terperinci? Voter memungkinkan logika otorisasi yang kompleks dan dapat digunakan kembali, memisahkan aturan bisnis dari kode controller. ```php // src/Security/Voter/ArticleVoter.php namespace App\Security\Voter; use App\Entity\Article; use App\Entity\User; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; class ArticleVoter extends Voter { public const VIEW = 'ARTICLE_VIEW'; public const EDIT = 'ARTICLE_EDIT'; public const DELETE = 'ARTICLE_DELETE'; protected function supports(string $attribute, mixed $subject): bool { // Voter ini hanya menangani Article dan atribut ini return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE]) && $subject instanceof Article; } protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { $user = $token->getUser(); $article = $subject; // Artikel yang dipublikasi terlihat untuk semua orang if ($attribute === self::VIEW && $article->isPublished()) { return true; } // Tindakan lain memerlukan user yang terautentikasi if (!$user instanceof User) { return false; } return match ($attribute) { self::VIEW => $this->canView($article, $user), self::EDIT => $this->canEdit($article, $user), self::DELETE => $this->canDelete($article, $user), default => false, }; } private function canView(Article $article, User $user): bool { // Draft hanya terlihat oleh penulis atau admin return $article->getAuthor() === $user || in_array('ROLE_ADMIN', $user->getRoles()); } private function canEdit(Article $article, User $user): bool { // Hanya penulis yang dapat mengedit return $article->getAuthor() === $user; } private function canDelete(Article $article, User $user): bool { // Penulis atau admin dapat menghapus return $article->getAuthor() === $user || in_array('ROLE_ADMIN', $user->getRoles()); } } ``` ```php // src/Controller/ArticleController.php // Penggunaan Voter di controller use Symfony\Component\Security\Http\Attribute\IsGranted; class ArticleController extends AbstractController { #[Route('/articles/{id}/edit', name: 'article_edit')] #[IsGranted(ArticleVoter::EDIT, subject: 'article')] public function edit(Article $article): Response { // Otorisasi diperiksa secara otomatis // 403 jika voter menolak akses } // Alternatif programatik #[Route('/articles/{id}', name: 'article_show')] public function show(Article $article): Response { $this->denyAccessUnlessGranted(ArticleVoter::VIEW, $article); // Atau dengan kondisi if (!$this->isGranted(ArticleVoter::EDIT, $article)) { // Sembunyikan tombol edit } } } ``` ```twig {# Di Twig #} {% if is_granted('ARTICLE_EDIT', article) %} Edit {% endif %} ``` Voter ditemukan secara otomatis dan dikonsultasikan saat panggilan `isGranted()`. Strategi default memberi akses bila setidaknya satu Voter memberi suara positif. ### Pertanyaan 10: Bagaimana cara mengamankan API dengan JWT di Symfony? Autentikasi JWT (JSON Web Token) adalah solusi standar untuk API stateless. Symfony biasanya menggunakan LexikJWTAuthenticationBundle. ```yaml # config/packages/lexik_jwt_authentication.yaml lexik_jwt_authentication: secret_key: '%env(resolve:JWT_SECRET_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%' pass_phrase: '%env(JWT_PASSPHRASE)%' token_ttl: 3600 # 1 jam ``` ```php // src/Controller/Api/AuthController.php namespace App\Controller\Api; use App\Entity\User; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; #[Route('/api')] class AuthController extends AbstractController { #[Route('/login', name: 'api_login', methods: ['POST'])] public function login( Request $request, UserPasswordHasherInterface $passwordHasher, JWTTokenManagerInterface $jwtManager, ): JsonResponse { $data = json_decode($request->getContent(), true); $user = $this->userRepository->findOneBy(['email' => $data['email']]); if (!$user || !$passwordHasher->isPasswordValid($user, $data['password'])) { return new JsonResponse(['error' => 'Invalid credentials'], 401); } // Buat token JWT $token = $jwtManager->create($user); return new JsonResponse([ 'token' => $token, 'user' => [ 'id' => $user->getId(), 'email' => $user->getEmail(), 'roles' => $user->getRoles(), ], ]); } #[Route('/refresh-token', name: 'api_refresh_token', methods: ['POST'])] public function refreshToken(): JsonResponse { // Ditangani otomatis oleh bundle jika dikonfigurasi // Mengembalikan token baru dari refresh token } } ``` ```php // src/EventListener/JWTCreatedListener.php // Kustomisasi payload JWT namespace App\EventListener; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent; class JWTCreatedListener { public function onJWTCreated(JWTCreatedEvent $event): void { $user = $event->getUser(); $payload = $event->getData(); // Tambahkan data kustom ke token $payload['user_id'] = $user->getId(); $payload['email'] = $user->getEmail(); $payload['permissions'] = $user->getPermissions(); $event->setData($payload); } } ``` Token JWT dikirim di header `Authorization: Bearer `. Bundle secara otomatis memverifikasi tanda tangan dan kedaluwarsa. ## Form Symfony ### Pertanyaan 11: Bagaimana cara membuat form lanjutan dengan validasi? Komponen Form Symfony menghasilkan form HTML, mengelola pengiriman, dan memvalidasi data dengan constraint. ```php // src/Form/ArticleType.php namespace App\Form; use App\Entity\Article; use App\Entity\Category; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; class ArticleType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('title', TextType::class, [ 'label' => 'Judul artikel', 'attr' => ['placeholder' => 'Masukkan judul...'], 'constraints' => [ new Assert\NotBlank(message: 'Judul wajib diisi'), new Assert\Length( min: 10, max: 255, minMessage: 'Judul minimal {{ limit }} karakter', ), ], ]) ->add('content', TextareaType::class, [ 'label' => 'Konten', 'constraints' => [ new Assert\NotBlank(), new Assert\Length(min: 100), ], ]) ->add('category', EntityType::class, [ 'class' => Category::class, 'choice_label' => 'name', 'placeholder' => 'Pilih kategori', 'query_builder' => function ($repo) { return $repo->createQueryBuilder('c') ->where('c.active = true') ->orderBy('c.name', 'ASC'); }, ]) ->add('coverImage', FileType::class, [ 'label' => 'Gambar sampul', 'mapped' => false, // Tidak terkait dengan entitas 'required' => false, 'constraints' => [ new Assert\Image( maxSize: '5M', mimeTypes: ['image/jpeg', 'image/png', 'image/webp'], mimeTypesMessage: 'Format gambar tidak didukung', ), ], ]) ->add('publishedAt', DateTimeType::class, [ 'widget' => 'single_text', 'required' => false, 'label' => 'Tanggal publikasi', ]); // Event listener untuk memodifikasi form secara dinamis $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { $article = $event->getData(); $form = $event->getForm(); // Tambahkan field hanya saat edit if ($article && $article->getId()) { $form->add('slug', TextType::class, [ 'disabled' => true, 'help' => 'Slug tidak dapat diubah', ]); } }); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => Article::class, 'validation_groups' => ['Default', 'article_creation'], ]); } } ``` ```php // src/Controller/ArticleController.php #[Route('/articles/new', name: 'article_new')] public function new(Request $request, SluggerInterface $slugger): Response { $article = new Article(); $form = $this->createForm(ArticleType::class, $article); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // Penanganan upload file $coverImage = $form->get('coverImage')->getData(); if ($coverImage) { $filename = $slugger->slug($article->getTitle()).'-'.uniqid().'.'.$coverImage->guessExtension(); $coverImage->move($this->getParameter('covers_directory'), $filename); $article->setCoverImageFilename($filename); } $this->entityManager->persist($article); $this->entityManager->flush(); $this->addFlash('success', 'Artikel berhasil dibuat!'); return $this->redirectToRoute('article_show', ['id' => $article->getId()]); } return $this->render('article/new.html.twig', [ 'form' => $form, ]); } ``` FormEvents (`PRE_SET_DATA`, `POST_SUBMIT`, dll.) memungkinkan memodifikasi field secara dinamis berdasarkan konteks. ### Pertanyaan 12: Bagaimana cara mengimplementasikan validasi kustom dengan constraint? Symfony memungkinkan membuat constraint validasi kustom untuk aturan bisnis kompleks. ```php // src/Validator/UniqueEmail.php // Constraint kustom namespace App\Validator; use Symfony\Component\Validator\Constraint; #[\Attribute(\Attribute::TARGET_PROPERTY)] class UniqueEmail extends Constraint { public string $message = 'Email "{{ value }}" sudah digunakan.'; public ?int $excludeId = null; // Untuk mengecualikan user saat ini ketika update } ``` ```php // src/Validator/UniqueEmailValidator.php // Validator yang terkait dengan constraint namespace App\Validator; use App\Repository\UserRepository; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\UnexpectedTypeException; class UniqueEmailValidator extends ConstraintValidator { public function __construct( private readonly UserRepository $userRepository, ) {} public function validate(mixed $value, Constraint $constraint): void { if (!$constraint instanceof UniqueEmail) { throw new UnexpectedTypeException($constraint, UniqueEmail::class); } if (null === $value || '' === $value) { return; // NotBlank menangani nilai kosong } $existingUser = $this->userRepository->findOneBy(['email' => $value]); // Cek apakah user dengan email ini ada // dan bukan user saat ini (saat update) if ($existingUser && $existingUser->getId() !== $constraint->excludeId) { $this->context->buildViolation($constraint->message) ->setParameter('{{ value }}', $value) ->addViolation(); } } } ``` ```php // src/Entity/User.php // Penggunaan constraint pada entitas use App\Validator as AppAssert; use Symfony\Component\Validator\Constraints as Assert; class User { #[Assert\NotBlank] #[Assert\Email] #[AppAssert\UniqueEmail] private ?string $email = null; #[Assert\NotBlank] #[Assert\Length(min: 8)] #[Assert\Regex( pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/', message: 'Password harus mengandung huruf besar, huruf kecil, dan angka' )] private ?string $plainPassword = null; } ``` ```php // Constraint pada level kelas untuk validasi multi-field // src/Validator/PasswordMatch.php #[\Attribute(\Attribute::TARGET_CLASS)] class PasswordMatch extends Constraint { public string $message = 'Password tidak cocok.'; public function getTargets(): string { return self::CLASS_CONSTRAINT; } } ``` Constraint kustom ditemukan secara otomatis. Sufiks `Validator` wajib untuk validator. ## Messenger dan komunikasi asinkron ### Pertanyaan 13: Bagaimana cara mengimplementasikan pemrosesan asinkron dengan Messenger? Symfony Messenger mengirim pesan ke antrian untuk pemrosesan asinkron, meningkatkan responsivitas aplikasi. ```php // src/Message/SendWelcomeEmail.php // Pesan (DTO yang berisi data) namespace App\Message; final class SendWelcomeEmail { public function __construct( public readonly int $userId, public readonly string $locale = 'en', ) {} } ``` ```php // src/MessageHandler/SendWelcomeEmailHandler.php // Handler yang memproses pesan namespace App\MessageHandler; use App\Message\SendWelcomeEmail; use App\Repository\UserRepository; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final class SendWelcomeEmailHandler { public function __construct( private readonly UserRepository $userRepository, private readonly MailerInterface $mailer, ) {} public function __invoke(SendWelcomeEmail $message): void { $user = $this->userRepository->find($message->userId); if (!$user) { return; // User dihapus selama proses berlangsung } $email = (new TemplatedEmail()) ->to($user->getEmail()) ->subject('Selamat datang di platform kami!') ->htmlTemplate('emails/welcome.html.twig') ->context([ 'user' => $user, 'locale' => $message->locale, ]); $this->mailer->send($email); } } ``` ```yaml # config/packages/messenger.yaml framework: messenger: failure_transport: failed transports: async: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' retry_strategy: max_retries: 3 delay: 1000 multiplier: 2 failed: dsn: 'doctrine://default?queue_name=failed' routing: # Routing pesan ke transport async App\Message\SendWelcomeEmail: async App\Message\ProcessImage: async App\Message\GenerateReport: async ``` ```php // src/Controller/RegistrationController.php // Pengiriman pesan use Symfony\Component\Messenger\MessageBusInterface; class RegistrationController extends AbstractController { #[Route('/register', name: 'app_register', methods: ['POST'])] public function register( Request $request, MessageBusInterface $bus, ): Response { // ... pembuatan user // Dispatch asinkron - email akan dikirim di background $bus->dispatch(new SendWelcomeEmail( userId: $user->getId(), locale: $request->getLocale(), )); // Respons cepat ke user return $this->redirectToRoute('app_login'); } } ``` Worker dijalankan dengan `php bin/console messenger:consume async -vv`. Di produksi, gunakan Supervisor untuk menjaga worker tetap berjalan. ### Pertanyaan 14: Bagaimana cara menangani error dan retry dengan Messenger? Messenger menyediakan mekanisme tangguh untuk menangani kegagalan: retry otomatis, dead letter queue, dan penanganan manual pesan yang gagal. ```php // src/MessageHandler/ProcessPaymentHandler.php namespace App\MessageHandler; use App\Exception\PaymentFailedException; use App\Exception\PaymentRetryableException; use App\Message\ProcessPayment; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException; use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; #[AsMessageHandler] final class ProcessPaymentHandler { public function __invoke(ProcessPayment $message): void { try { $this->paymentGateway->process($message->orderId); } catch (PaymentRetryableException $e) { // Error sementara (timeout, rate limit) → retry throw new RecoverableMessageHandlingException( $e->getMessage(), previous: $e ); } catch (PaymentFailedException $e) { // Error permanen (kartu tidak valid) → tanpa retry throw new UnrecoverableMessageHandlingException( $e->getMessage(), previous: $e ); } } } ``` ```php // src/Message/ProcessPayment.php // Konfigurasi retry pada level pesan namespace App\Message; use Symfony\Component\Messenger\Stamp\DelayStamp; final class ProcessPayment { public function __construct( public readonly int $orderId, public readonly int $attempt = 1, ) {} // Penundaan retry kustom berdasarkan jumlah upaya public function getRetryDelay(): int { return match ($this->attempt) { 1 => 5000, // 5 detik 2 => 30000, // 30 detik 3 => 300000, // 5 menit default => 600000, }; } } ``` ```bash # Perintah pengelolaan pesan yang gagal php bin/console messenger:failed:show # Tampilkan pesan yang gagal php bin/console messenger:failed:retry # Retry semua pesan php bin/console messenger:failed:retry 123 # Retry pesan tertentu php bin/console messenger:failed:remove 123 # Hapus pesan ``` Strategi retry dan transport "failed" memastikan tidak ada pesan yang hilang. Pesan dapat dianalisis dan di-retry secara manual. ## Pengujian di Symfony ### Pertanyaan 15: Bagaimana cara menyusun pengujian di Symfony? Symfony menyediakan PHPUnit dengan helper khusus untuk menguji berbagai layer aplikasi: unit, fungsional, dan integrasi. ```php // tests/Unit/Service/PriceCalculatorTest.php // Unit test: menguji kelas terisolasi namespace App\Tests\Unit\Service; use App\Service\PriceCalculator; use PHPUnit\Framework\TestCase; class PriceCalculatorTest extends TestCase { private PriceCalculator $calculator; protected function setUp(): void { $this->calculator = new PriceCalculator(); } public function testCalculateTotalWithoutDiscount(): void { $total = $this->calculator->calculateTotal(100.00, 0); $this->assertEquals(100.00, $total); } public function testCalculateTotalWithPercentageDiscount(): void { $total = $this->calculator->calculateTotal(100.00, 20); $this->assertEquals(80.00, $total); } /** * @dataProvider discountProvider */ public function testCalculateTotalWithVariousDiscounts( float $price, int $discount, float $expected ): void { $total = $this->calculator->calculateTotal($price, $discount); $this->assertEquals($expected, $total); } public static function discountProvider(): array { return [ 'no discount' => [100.00, 0, 100.00], '10% discount' => [100.00, 10, 90.00], '50% discount' => [200.00, 50, 100.00], 'max discount' => [100.00, 100, 0.00], ]; } } ``` ```php // tests/Functional/Controller/ArticleControllerTest.php // Functional test: menguji controller via HTTP namespace App\Tests\Functional\Controller; use App\Entity\Article; use App\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class ArticleControllerTest extends WebTestCase { private $client; private EntityManagerInterface $entityManager; protected function setUp(): void { $this->client = static::createClient(); $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); } public function testListArticlesReturnsOk(): void { $this->client->request('GET', '/articles'); $this->assertResponseIsSuccessful(); $this->assertSelectorExists('h1'); } public function testCreateArticleRequiresAuthentication(): void { $this->client->request('GET', '/articles/new'); $this->assertResponseRedirects('/login'); } public function testAuthenticatedUserCanCreateArticle(): void { // Autentikasi $user = $this->createUser(); $this->client->loginUser($user); // Akses form $crawler = $this->client->request('GET', '/articles/new'); $this->assertResponseIsSuccessful(); // Pengiriman form $form = $crawler->selectButton('Buat')->form([ 'article[title]' => 'Test Article Title', 'article[content]' => 'Ini adalah konten artikel uji saya dengan jumlah karakter yang cukup.', ]); $this->client->submit($form); // Verifikasi $this->assertResponseRedirects(); $this->client->followRedirect(); $this->assertSelectorTextContains('h1', 'Test Article Title'); // Verifikasi di database $article = $this->entityManager->getRepository(Article::class) ->findOneBy(['title' => 'Test Article Title']); $this->assertNotNull($article); } private function createUser(): User { $user = new User(); $user->setEmail('test@example.com'); $user->setPassword('$2y$13$hashedpassword'); $this->entityManager->persist($user); $this->entityManager->flush(); return $user; } protected function tearDown(): void { // Bersihkan database uji $this->entityManager->getConnection()->executeStatement('DELETE FROM article'); $this->entityManager->getConnection()->executeStatement('DELETE FROM user'); parent::tearDown(); } } ``` Sebaiknya pisahkan unit test (tanpa kernel), functional test (dengan kernel), dan integration test (dengan service nyata). ### Pertanyaan 16: Bagaimana cara menggunakan fixtures dan DatabaseResetter? Fixtures mengisi database dengan data uji yang realistis. Komponen DoctrineTestBundle memudahkan reset antar pengujian. ```php // src/DataFixtures/ArticleFixtures.php namespace App\DataFixtures; use App\Entity\Article; use App\Entity\User; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; class ArticleFixtures extends Fixture implements DependentFixtureInterface { public function load(ObjectManager $manager): void { for ($i = 1; $i <= 20; $i++) { $article = new Article(); $article->setTitle("Test Article Number $i"); $article->setSlug("test-article-$i"); $article->setContent("Konten detail artikel nomor $i..."); $article->setStatus($i <= 15 ? 'published' : 'draft'); $article->setPublishedAt($i <= 15 ? new \DateTimeImmutable("-$i days") : null); // Referensi ke user yang dibuat oleh UserFixtures $article->setAuthor($this->getReference('user-'.($i % 3), User::class)); $manager->persist($article); // Buat referensi untuk fixtures lain $this->addReference("article-$i", $article); } $manager->flush(); } public function getDependencies(): array { // UserFixtures harus dimuat sebelum ArticleFixtures return [ UserFixtures::class, ]; } } ``` ```php // src/DataFixtures/UserFixtures.php namespace App\DataFixtures; use App\Entity\User; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Persistence\ObjectManager; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; class UserFixtures extends Fixture implements FixtureGroupInterface { public function __construct( private readonly UserPasswordHasherInterface $passwordHasher, ) {} public function load(ObjectManager $manager): void { $users = [ ['email' => 'admin@example.com', 'roles' => ['ROLE_ADMIN'], 'ref' => 'user-0'], ['email' => 'author@example.com', 'roles' => ['ROLE_AUTHOR'], 'ref' => 'user-1'], ['email' => 'user@example.com', 'roles' => ['ROLE_USER'], 'ref' => 'user-2'], ]; foreach ($users as $userData) { $user = new User(); $user->setEmail($userData['email']); $user->setRoles($userData['roles']); $user->setPassword($this->passwordHasher->hashPassword($user, 'password123')); $manager->persist($user); $this->addReference($userData['ref'], $user); } $manager->flush(); } public static function getGroups(): array { return ['test', 'dev']; } } ``` ```php // tests/Functional/ArticleControllerTest.php // Penggunaan DAMADoctrineTestBundle untuk reset otomatis namespace App\Tests\Functional; use App\Entity\Article; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class ArticleControllerTest extends WebTestCase { use \DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver; public function testPublishedArticlesCount(): void { $client = static::createClient(); $em = static::getContainer()->get(EntityManagerInterface::class); $count = $em->getRepository(Article::class) ->count(['status' => 'published']); $this->assertEquals(15, $count); // Sesuai fixtures } } ``` ```bash # Pemuatan fixtures php bin/console doctrine:fixtures:load php bin/console doctrine:fixtures:load --group=test php bin/console doctrine:fixtures:load --env=test ``` DAMADoctrineTestBundle membungkus setiap pengujian dalam transaksi yang di-rollback, sehingga fixtures tidak perlu dimuat ulang antar pengujian. ## Arsitektur dan pola lanjutan ### Pertanyaan 17: Bagaimana cara mengimplementasikan CQRS dengan Symfony? CQRS (Command Query Responsibility Segregation) memisahkan operasi baca dari tulis, memungkinkan optimasi independen. ```php // src/Message/Command/CreateArticleCommand.php // Command: mewakili niat modifikasi namespace App\Message\Command; final class CreateArticleCommand { public function __construct( public readonly string $title, public readonly string $content, public readonly int $authorId, public readonly array $tagIds = [], ) {} } ``` ```php // src/MessageHandler/Command/CreateArticleCommandHandler.php // CommandHandler: mengeksekusi modifikasi namespace App\MessageHandler\Command; use App\Entity\Article; use App\Message\Command\CreateArticleCommand; use App\Repository\TagRepository; use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\String\Slugger\SluggerInterface; #[AsMessageHandler] final class CreateArticleCommandHandler { public function __construct( private readonly EntityManagerInterface $entityManager, private readonly UserRepository $userRepository, private readonly TagRepository $tagRepository, private readonly SluggerInterface $slugger, ) {} public function __invoke(CreateArticleCommand $command): Article { $author = $this->userRepository->find($command->authorId) ?? throw new \InvalidArgumentException('Author not found'); $article = new Article(); $article->setTitle($command->title); $article->setSlug($this->slugger->slug($command->title)->lower()); $article->setContent($command->content); $article->setAuthor($author); foreach ($command->tagIds as $tagId) { $tag = $this->tagRepository->find($tagId); if ($tag) { $article->addTag($tag); } } $this->entityManager->persist($article); $this->entityManager->flush(); return $article; } } ``` ```php // src/Message/Query/GetArticleBySlugQuery.php // Query: mewakili permintaan baca namespace App\Message\Query; final class GetArticleBySlugQuery { public function __construct( public readonly string $slug, public readonly bool $withComments = false, ) {} } ``` ```php // src/MessageHandler/Query/GetArticleBySlugQueryHandler.php // QueryHandler: mengambil data (dapat menggunakan read model yang dioptimasi) namespace App\MessageHandler\Query; use App\DTO\ArticleDTO; use App\Message\Query\GetArticleBySlugQuery; use App\Repository\ArticleRepository; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final class GetArticleBySlugQueryHandler { public function __construct( private readonly ArticleRepository $repository, ) {} public function __invoke(GetArticleBySlugQuery $query): ?ArticleDTO { $qb = $this->repository->createQueryBuilder('a') ->select('a', 'u') ->leftJoin('a.author', 'u') ->where('a.slug = :slug') ->setParameter('slug', $query->slug); if ($query->withComments) { $qb->addSelect('c') ->leftJoin('a.comments', 'c'); } $article = $qb->getQuery()->getOneOrNullResult(); return $article ? ArticleDTO::fromEntity($article) : null; } } ``` ```php // src/Controller/ArticleController.php // Penggunaan Command dan Query Bus use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\Stamp\HandledStamp; class ArticleController extends AbstractController { public function __construct( private readonly MessageBusInterface $commandBus, private readonly MessageBusInterface $queryBus, ) {} #[Route('/articles', methods: ['POST'])] public function create(Request $request): Response { $data = json_decode($request->getContent(), true); $envelope = $this->commandBus->dispatch(new CreateArticleCommand( title: $data['title'], content: $data['content'], authorId: $this->getUser()->getId(), )); $article = $envelope->last(HandledStamp::class)->getResult(); return $this->json($article, 201); } #[Route('/articles/{slug}', methods: ['GET'])] public function show(string $slug): Response { $envelope = $this->queryBus->dispatch(new GetArticleBySlugQuery( slug: $slug, withComments: true, )); $article = $envelope->last(HandledStamp::class)->getResult(); return $this->json($article); } } ``` CQRS memungkinkan mengoptimasi pembacaan (caching, projeksi) dan penulisan (validasi, event) secara terpisah. ### Pertanyaan 18: Bagaimana cara mengimplementasikan Repository Pattern dengan benar di Symfony? Repository Pattern di Symfony sudah hadir melalui Doctrine, tetapi dapat diperkaya dengan interface dan metode bisnis. ```php // src/Repository/Contract/ArticleRepositoryInterface.php // Interface untuk decoupling dan testabilitas namespace App\Repository\Contract; use App\Entity\Article; use Doctrine\Common\Collections\Collection; interface ArticleRepositoryInterface { public function find(int $id): ?Article; public function findBySlug(string $slug): ?Article; public function findPublished(int $limit = 20, int $offset = 0): array; public function findByAuthor(int $authorId): array; public function save(Article $article, bool $flush = false): void; public function remove(Article $article, bool $flush = false): void; } ``` ```php // src/Repository/ArticleRepository.php // Implementasi Doctrine dari repository namespace App\Repository; use App\Entity\Article; use App\Repository\Contract\ArticleRepositoryInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class ArticleRepository extends ServiceEntityRepository implements ArticleRepositoryInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Article::class); } public function findBySlug(string $slug): ?Article { return $this->createQueryBuilder('a') ->addSelect('u', 't') ->leftJoin('a.author', 'u') ->leftJoin('a.tags', 't') ->where('a.slug = :slug') ->setParameter('slug', $slug) ->getQuery() ->getOneOrNullResult(); } public function findPublished(int $limit = 20, int $offset = 0): array { return $this->createQueryBuilder('a') ->addSelect('u') ->leftJoin('a.author', 'u') ->where('a.status = :status') ->setParameter('status', 'published') ->orderBy('a.publishedAt', 'DESC') ->setMaxResults($limit) ->setFirstResult($offset) ->getQuery() ->getResult(); } public function findByAuthor(int $authorId): array { return $this->createQueryBuilder('a') ->where('a.author = :authorId') ->setParameter('authorId', $authorId) ->orderBy('a.createdAt', 'DESC') ->getQuery() ->getResult(); } public function save(Article $article, bool $flush = false): void { $this->getEntityManager()->persist($article); if ($flush) { $this->getEntityManager()->flush(); } } public function remove(Article $article, bool $flush = false): void { $this->getEntityManager()->remove($article); if ($flush) { $this->getEntityManager()->flush(); } } // Metode query kompleks public function findPopularByCategory(int $categoryId, int $limit = 5): array { return $this->createQueryBuilder('a') ->addSelect('u') ->leftJoin('a.author', 'u') ->where('a.category = :categoryId') ->andWhere('a.status = :status') ->setParameter('categoryId', $categoryId) ->setParameter('status', 'published') ->orderBy('a.viewCount', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult(); } } ``` ```yaml # config/services.yaml # Pengikatan interface ke implementasi services: App\Repository\Contract\ArticleRepositoryInterface: alias: App\Repository\ArticleRepository ``` Interface memungkinkan membuat implementasi uji (InMemoryArticleRepository) atau mengganti sumber data tanpa mengubah kode bisnis. ### Pertanyaan 19: Bagaimana cara mengelola konfigurasi dan environment di Symfony? Symfony menggunakan sistem konfigurasi fleksibel dengan dukungan variabel environment, secret, dan file YAML per environment. ```yaml # config/packages/framework.yaml # Konfigurasi default framework: secret: '%env(APP_SECRET)%' http_method_override: false handle_all_throwables: true session: handler_id: null cookie_secure: auto cookie_samesite: lax storage_factory_id: session.storage.factory.native php_errors: log: true ``` ```yaml # config/packages/prod/framework.yaml # Override untuk produksi framework: session: handler_id: '%env(REDIS_URL)%' cookie_secure: true when@prod: framework: router: strict_requirements: null ``` ```php // config/secrets/prod/prod.decrypt.private.php // Pengelolaan secret sensitif (terenkripsi) // php bin/console secrets:set DATABASE_URL --env=prod // php bin/console secrets:list --reveal --env=prod ``` ```php // src/DependencyInjection/Configuration.php // Konfigurasi kustom untuk bundle namespace App\DependencyInjection; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; class Configuration implements ConfigurationInterface { public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('my_app'); $treeBuilder->getRootNode() ->children() ->scalarNode('api_key') ->isRequired() ->cannotBeEmpty() ->end() ->integerNode('cache_ttl') ->defaultValue(3600) ->min(0) ->end() ->arrayNode('features') ->addDefaultsIfNotSet() ->children() ->booleanNode('dark_mode')->defaultTrue()->end() ->booleanNode('beta_features')->defaultFalse()->end() ->end() ->end() ->end(); return $treeBuilder; } } ``` ```php // src/Service/ConfigurableService.php // Injeksi konfigurasi namespace App\Service; use Symfony\Component\DependencyInjection\Attribute\Autowire; class ConfigurableService { public function __construct( #[Autowire('%env(API_KEY)%')] private readonly string $apiKey, #[Autowire('%kernel.project_dir%')] private readonly string $projectDir, #[Autowire('%my_app.cache_ttl%')] private readonly int $cacheTtl, ) {} } ``` Secret Symfony terenkripsi dan terversi. Gunakan `%env(...)%` untuk variabel runtime, parameter untuk nilai statis. ## Performa dan produksi ### Pertanyaan 20: Bagaimana cara mengoptimasi performa aplikasi Symfony? Optimasi mencakup beberapa tingkat: opcache, konfigurasi, cache aplikasi, dan query Doctrine. ```php // config/packages/prod/doctrine.yaml # Konfigurasi Doctrine yang dioptimasi untuk produksi doctrine: orm: auto_generate_proxy_classes: false metadata_cache_driver: type: pool pool: doctrine.system_cache_pool query_cache_driver: type: pool pool: doctrine.system_cache_pool result_cache_driver: type: pool pool: doctrine.result_cache_pool ``` ```yaml # config/packages/cache.yaml # Konfigurasi cache dengan Redis framework: cache: app: cache.adapter.redis system: cache.adapter.system pools: doctrine.result_cache_pool: adapter: cache.adapter.redis default_lifetime: 3600 doctrine.system_cache_pool: adapter: cache.adapter.system services: Redis: class: Redis calls: - connect: - '%env(REDIS_HOST)%' - '%env(int:REDIS_PORT)%' Symfony\Component\Cache\Adapter\RedisAdapter: arguments: - '@Redis' ``` ```php // src/Repository/ArticleRepository.php // Penggunaan result cache Doctrine public function findPopularCached(): array { return $this->createQueryBuilder('a') ->addSelect('u') ->leftJoin('a.author', 'u') ->where('a.status = :status') ->setParameter('status', 'published') ->orderBy('a.viewCount', 'DESC') ->setMaxResults(10) ->getQuery() ->enableResultCache(3600, 'popular_articles') // Cache 1 jam ->getResult(); } ``` ```bash # Perintah optimasi untuk produksi php bin/console cache:clear --env=prod php bin/console cache:warmup --env=prod php bin/console doctrine:cache:clear-metadata --env=prod php bin/console doctrine:cache:clear-query --env=prod # Hasilkan proxy Doctrine php bin/console doctrine:proxy:create-proxy-classes # Kompilasi autoloader yang dioptimasi composer install --no-dev --optimize-autoloader --classmap-authoritative ``` OPcache wajib aktif di produksi dengan pengaturan optimal. Warmup menghasilkan cache container dan router. ### Pertanyaan 21: Bagaimana cara mengonfigurasi logging dan monitoring di Symfony? Logging terstruktur dan monitoring yang tepat penting untuk mendiagnosis masalah di produksi. ```yaml # config/packages/prod/monolog.yaml monolog: handlers: main: type: fingers_crossed action_level: error handler: nested excluded_http_codes: [404, 405] buffer_size: 50 nested: type: stream path: '%kernel.logs_dir%/%kernel.environment%.log' level: debug formatter: monolog.formatter.json console: type: console process_psr_3_messages: false channels: ['!event', '!doctrine'] slack: type: slack token: '%env(SLACK_TOKEN)%' channel: '#alerts' level: critical bot_name: 'SymfonyBot' ``` ```php // src/Service/PaymentService.php // Logging terstruktur dengan konteks namespace App\Service; use Psr\Log\LoggerInterface; class PaymentService { public function __construct( private readonly LoggerInterface $logger, ) {} public function processPayment(Order $order): bool { $this->logger->info('Payment processing started', [ 'order_id' => $order->getId(), 'amount' => $order->getTotal(), 'currency' => $order->getCurrency(), 'user_id' => $order->getUser()->getId(), ]); try { $result = $this->gateway->charge($order); $this->logger->info('Payment successful', [ 'order_id' => $order->getId(), 'transaction_id' => $result->getTransactionId(), ]); return true; } catch (\Exception $e) { $this->logger->error('Payment failed', [ 'order_id' => $order->getId(), 'error' => $e->getMessage(), 'exception' => $e, ]); return false; } } } ``` ```php // src/EventSubscriber/RequestLoggerSubscriber.php // Logging request HTTP namespace App\EventSubscriber; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\TerminateEvent; use Symfony\Component\HttpKernel\KernelEvents; class RequestLoggerSubscriber implements EventSubscriberInterface { public function __construct( private readonly LoggerInterface $logger, ) {} public static function getSubscribedEvents(): array { return [ KernelEvents::TERMINATE => 'onKernelTerminate', ]; } public function onKernelTerminate(TerminateEvent $event): void { $request = $event->getRequest(); $response = $event->getResponse(); $this->logger->info('Request completed', [ 'method' => $request->getMethod(), 'uri' => $request->getRequestUri(), 'status' => $response->getStatusCode(), 'duration_ms' => round((microtime(true) - $request->server->get('REQUEST_TIME_FLOAT')) * 1000), 'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2), ]); } } ``` Format JSON memudahkan ingest oleh tool monitoring (ELK, Datadog). Channel memungkinkan pemfilteran berdasarkan tipe log. ### Pertanyaan 22: Bagaimana cara men-deploy aplikasi Symfony ke produksi? Deploy Symfony yang andal menggabungkan persiapan build, migrasi yang aman, dan switch atomik. ```bash #!/bin/bash # deploy.sh - Skrip deploy set -e RELEASE_DIR="/var/www/releases/$(date +%Y%m%d%H%M%S)" SHARED_DIR="/var/www/shared" CURRENT_LINK="/var/www/current" echo "Membuat direktori release..." mkdir -p $RELEASE_DIR echo "Cloning repository..." git clone --depth 1 --branch main git@github.com:org/repo.git $RELEASE_DIR echo "Memasang dependency..." cd $RELEASE_DIR composer install --no-dev --optimize-autoloader --classmap-authoritative echo "Menautkan file bersama..." ln -sf $SHARED_DIR/.env.local $RELEASE_DIR/.env.local ln -sf $SHARED_DIR/var/log $RELEASE_DIR/var/log ln -sf $SHARED_DIR/public/uploads $RELEASE_DIR/public/uploads echo "Menjalankan migrasi..." php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration echo "Memanaskan cache..." php bin/console cache:clear --env=prod --no-debug php bin/console cache:warmup --env=prod --no-debug echo "Mengatur izin..." chown -R www-data:www-data $RELEASE_DIR echo "Beralih ke release baru..." ln -sfn $RELEASE_DIR $CURRENT_LINK echo "Restart PHP-FPM..." sudo systemctl reload php8.3-fpm echo "Restart worker Messenger..." php bin/console messenger:stop-workers echo "Membersihkan release lama..." ls -dt /var/www/releases/*/ | tail -n +6 | xargs rm -rf echo "Deploy selesai!" ``` ```yaml # .github/workflows/deploy.yml # Deploy CI/CD dengan GitHub Actions name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: intl, pdo_pgsql, redis - name: Install dependencies run: composer install --no-dev --optimize-autoloader - name: Run tests run: php bin/phpunit - name: Deploy to production uses: appleboy/ssh-action@master with: host: ${{ secrets.PROD_HOST }} username: ${{ secrets.PROD_USER }} key: ${{ secrets.PROD_SSH_KEY }} script: | cd /var/www/app ./deploy.sh ``` Deploy atomik via symlink memungkinkan rollback seketika. Worker Messenger harus di-restart agar memuat kode baru. ## API Platform dan REST ### Pertanyaan 23: Bagaimana cara membangun API REST dengan API Platform? API Platform adalah solusi standar untuk membuat API REST dan GraphQL dengan Symfony, menyediakan dokumentasi otomatis dan standar HTTP. ```php // src/Entity/Article.php // Konfigurasi resource API Platform 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 ApiPlatform\Metadata\Put; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: ArticleRepository::class)] #[ApiResource( operations: [ new GetCollection( normalizationContext: ['groups' => ['article:list']], ), new Get( normalizationContext: ['groups' => ['article:read']], ), new Post( security: "is_granted('ROLE_AUTHOR')", denormalizationContext: ['groups' => ['article:write']], ), new Put( security: "is_granted('ARTICLE_EDIT', object)", ), new Patch( security: "is_granted('ARTICLE_EDIT', object)", ), new Delete( security: "is_granted('ARTICLE_DELETE', object)", ), ], order: ['publishedAt' => 'DESC'], paginationItemsPerPage: 20, )] class Article { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['article:list', 'article:read'])] private ?int $id = null; #[ORM\Column(length: 255)] #[Assert\NotBlank] #[Assert\Length(min: 10, max: 255)] #[Groups(['article:list', 'article:read', 'article:write'])] private ?string $title = null; #[ORM\Column(type: 'text')] #[Assert\NotBlank] #[Groups(['article:read', 'article:write'])] private ?string $content = null; #[ORM\ManyToOne(inversedBy: 'articles')] #[ORM\JoinColumn(nullable: false)] #[Groups(['article:list', 'article:read'])] private ?User $author = null; #[ORM\Column(nullable: true)] #[Groups(['article:list', 'article:read'])] private ?\DateTimeImmutable $publishedAt = null; } ``` ```php // src/State/ArticleProcessor.php // Processor kustom untuk logika bisnis namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\Article; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\String\Slugger\SluggerInterface; final class ArticleProcessor implements ProcessorInterface { public function __construct( private readonly ProcessorInterface $persistProcessor, private readonly Security $security, private readonly SluggerInterface $slugger, ) {} public function process( mixed $data, Operation $operation, array $uriVariables = [], array $context = [] ): Article { if ($data instanceof Article && !$data->getId()) { // Artikel baru: tetapkan penulis dan slug $data->setAuthor($this->security->getUser()); $data->setSlug($this->slugger->slug($data->getTitle())->lower()); } return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } } ``` ```yaml # config/packages/api_platform.yaml api_platform: title: 'My API' version: '1.0.0' formats: jsonld: ['application/ld+json'] json: ['application/json'] docs_formats: jsonld: ['application/ld+json'] jsonopenapi: ['application/vnd.openapi+json'] html: ['text/html'] defaults: pagination_items_per_page: 20 pagination_client_items_per_page: true pagination_maximum_items_per_page: 100 swagger: versions: [3] ``` API Platform secara otomatis menghasilkan dokumentasi OpenAPI dan menyediakan filter, paginasi, serta validasi. ### Pertanyaan 24: Bagaimana cara menyesuaikan operasi API Platform? API Platform memungkinkan membuat operasi kustom dengan controller atau State Provider/Processor. ```php // src/Entity/Article.php // Operasi kustom #[ApiResource( operations: [ // Operasi standar new GetCollection(), new Get(), // Operasi kustom dengan controller new Post( uriTemplate: '/articles/{id}/publish', controller: PublishArticleController::class, openapi: new Model\Operation( summary: 'Mempublikasikan artikel', description: 'Mengubah status artikel menjadi "published"', ), security: "is_granted('ARTICLE_EDIT', object)", ), // Operasi dengan State Provider kustom new GetCollection( uriTemplate: '/articles/trending', provider: TrendingArticlesProvider::class, openapiContext: ['summary' => 'Artikel sedang tren'], ), ], )] class Article { // ... } ``` ```php // src/Controller/PublishArticleController.php // Controller untuk operasi kustom namespace App\Controller; use App\Entity\Article; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpKernel\Attribute\AsController; #[AsController] class PublishArticleController extends AbstractController { public function __construct( private readonly EntityManagerInterface $entityManager, ) {} public function __invoke(Article $article): Article { if ($article->getStatus() === 'published') { throw $this->createNotFoundException('Artikel sudah dipublikasikan'); } $article->setStatus('published'); $article->setPublishedAt(new \DateTimeImmutable()); $this->entityManager->flush(); return $article; } } ``` ```php // src/State/TrendingArticlesProvider.php // State Provider untuk logika baca kustom namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; use App\Repository\ArticleRepository; final class TrendingArticlesProvider implements ProviderInterface { public function __construct( private readonly ArticleRepository $repository, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): array { // Logika kustom untuk artikel sedang tren return $this->repository->createQueryBuilder('a') ->where('a.status = :status') ->andWhere('a.publishedAt > :date') ->setParameter('status', 'published') ->setParameter('date', new \DateTimeImmutable('-7 days')) ->orderBy('a.viewCount', 'DESC') ->setMaxResults(10) ->getQuery() ->getResult(); } } ``` State Provider menangani pembacaan, State Processor menangani penulisan. Controller tetap tersedia untuk kasus kompleks. ### Pertanyaan 25: Bagaimana cara mengelola migrasi database di produksi? Migrasi Doctrine harus dirancang untuk berjalan tanpa downtime dan memungkinkan rollback yang mudah. ```php // migrations/Version20260202100000.php // Migrasi aman: menambahkan kolom nullable declare(strict_types=1); namespace DoctrineMigrations; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; final class Version20260202100000 extends AbstractMigration { public function getDescription(): string { return 'Add role column to users table (nullable first)'; } public function up(Schema $schema): void { // Langkah 1: tambahkan kolom nullable $this->addSql('ALTER TABLE users ADD role VARCHAR(50) DEFAULT NULL'); // Buat indeks dengan CONCURRENTLY (PostgreSQL - tidak memblokir) $this->addSql('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_role ON users (role)'); } public function down(Schema $schema): void { $this->addSql('DROP INDEX IF EXISTS idx_users_role'); $this->addSql('ALTER TABLE users DROP COLUMN role'); } } ``` ```php // migrations/Version20260202100001.php // Migrasi data (terpisah) final class Version20260202100001 extends AbstractMigration { public function getDescription(): string { return 'Populate role column with default value'; } public function up(Schema $schema): void { // Migrasi batch untuk tabel besar $this->addSql("UPDATE users SET role = 'ROLE_USER' WHERE role IS NULL"); } public function down(Schema $schema): void { // Tidak perlu rollback untuk data } } ``` ```php // migrations/Version20260202100002.php // Migrasi terakhir: jadikan kolom NOT NULL final class Version20260202100002 extends AbstractMigration { public function getDescription(): string { return 'Make role column NOT NULL'; } public function up(Schema $schema): void { // Pemeriksaan awal $count = $this->connection->fetchOne('SELECT COUNT(*) FROM users WHERE role IS NULL'); if ($count > 0) { throw new \RuntimeException("$count users still have NULL role"); } $this->addSql('ALTER TABLE users ALTER COLUMN role SET NOT NULL'); $this->addSql("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'ROLE_USER'"); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE users ALTER COLUMN role DROP NOT NULL'); $this->addSql('ALTER TABLE users ALTER COLUMN role DROP DEFAULT'); } } ``` ```bash # Perintah pengelolaan migrasi php bin/console doctrine:migrations:status # Status migrasi php bin/console doctrine:migrations:migrate # Jalankan migrasi php bin/console doctrine:migrations:migrate prev # Rollback yang terakhir php bin/console doctrine:migrations:diff # Generate migrasi dari schema php bin/console doctrine:migrations:execute --down # Rollback spesifik ``` Strategi expand-contract (3 deploy) menjamin deploy tanpa downtime: tambah nullable → migrasi data → tambahkan constraint. ## Kesimpulan 25 pertanyaan ini mencakup esensi wawancara Symfony, dari fundamental Service Container hingga pola produksi tingkat lanjut. Daftar persiapan: - ✅ Service Container dan dependency injection - ✅ Doctrine ORM: relasi, query, filter - ✅ Keamanan: autentikasi, voter, JWT - ✅ Form dan validasi kustom - ✅ Messenger: pemrosesan asinkron dan penanganan error - ✅ Pengujian: unit, fungsional, fixtures - ✅ Arsitektur: CQRS, Repository Pattern - ✅ API Platform: REST, operasi kustom - ✅ Produksi: performa, logging, deploy > **Pelajari lebih lanjut** > > Setiap pertanyaan layak dieksplorasi lebih dalam dengan dokumentasi resmi Symfony. Recruiter menghargai kandidat yang memahami pilihan arsitektural framework dan dapat menjelaskan keputusan teknis mereka. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/id/blog/symfony/symfony-interview-questions